diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 60b8e1fdf..0af88e5f2 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -170,7 +170,13 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent } state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Plan: initial.Plan, Events: []CloudAgentEvent{}, StepLimits: limits} if len(initial.Skills) > 0 { - state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "text": fmt.Sprintf("已启用 %d 个技能,正文将按需读取", len(initial.Skills))}) + // skillIds makes the enablement auditable: usage telemetry can attribute a + // run to the skills it actually loaded instead of only counting the total. + skillIDs := make([]string, 0, len(initial.Skills)) + for _, skill := range initial.Skills { + skillIDs = append(skillIDs, skill.ID) + } + state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "skillIds": skillIDs, "text": fmt.Sprintf("已启用 %d 个技能,正文将按需读取", len(initial.Skills))}) } run := &model.CloudAgentExecution{ID: task.ID, UserID: task.UserID, Status: "running", Revision: 1, CreatedAt: task.CreatedAt, UpdatedAt: time.Now()} if err := cloudAgentSave(run, &state); err != nil { diff --git a/backend/internal/app/cloud_agent_skill_usage.go b/backend/internal/app/cloud_agent_skill_usage.go new file mode 100644 index 000000000..66e166f5b --- /dev/null +++ b/backend/internal/app/cloud_agent_skill_usage.go @@ -0,0 +1,203 @@ +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 +} diff --git a/backend/internal/app/cloud_agent_skill_usage_test.go b/backend/internal/app/cloud_agent_skill_usage_test.go new file mode 100644 index 000000000..6ddd3e5ac --- /dev/null +++ b/backend/internal/app/cloud_agent_skill_usage_test.go @@ -0,0 +1,253 @@ +package app + +import ( + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func cloudAgentUsageEvent(runID, kind, tool string, payload map[string]any) CloudAgentEvent { + return CloudAgentEvent{RunID: runID, Type: kind, Payload: payload} +} + +func TestCloudAgentSkillUsageAggregatesLoadSearchAndReads(t *testing.T) { + events := []CloudAgentEvent{ + cloudAgentUsageEvent("run-1", "tool_completed", "skills_load", map[string]any{ + "toolName": "skills_load", + "skillIds": []any{"s1", "s2"}, + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_search", map[string]any{ + "toolName": "skill_search", + "result": map[string]any{"matches": []any{ + map[string]any{"skillId": "s1", "path": "SKILL.md", "score": 3}, + }}, + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "skillName": "短剧手册", "path": "SKILL.md", + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "skillName": "短剧手册", "path": "cards/ssqr-twist-ending.md", + }), + cloudAgentUsageEvent("run-2", "tool_completed", "skills_load", map[string]any{ + "toolName": "skills_load", + "skillIds": []any{"s1"}, + }), + } + + usage := cloudAgentSkillUsageFromEvents(events) + + if usage.ScannedRuns != 2 { + t.Fatalf("scannedRuns = %d, want 2", usage.ScannedRuns) + } + if usage.SearchCalls != 1 { + t.Fatalf("searchCalls = %d, want 1", usage.SearchCalls) + } + if len(usage.Skills) != 2 { + t.Fatalf("skills = %d, want 2", len(usage.Skills)) + } + // s1 has more runs enabled, so it sorts first. + first := usage.Skills[0] + if first.SkillID != "s1" { + t.Fatalf("first skill = %s, want s1", first.SkillID) + } + if first.SkillName != "短剧手册" { + t.Fatalf("skillName = %q, want 短剧手册", first.SkillName) + } + if first.RunsEnabled != 2 || first.SearchHits != 1 || first.ReadCalls != 2 { + t.Fatalf("s1 counters = %+v", first) + } + if first.EntryReads != 1 || first.CardReads != 1 { + t.Fatalf("s1 entry/card reads = %d/%d, want 1/1", first.EntryReads, first.CardReads) + } + if len(first.TopCards) != 1 || first.TopCards[0].Path != "cards/ssqr-twist-ending.md" { + t.Fatalf("s1 topCards = %+v", first.TopCards) + } + if usage.Skills[1].RunsEnabled != 1 { + t.Fatalf("s2 runsEnabled = %d, want 1", usage.Skills[1].RunsEnabled) + } +} + +func TestCloudAgentSkillUsageSeparatesEntryAndCardReads(t *testing.T) { + events := []CloudAgentEvent{ + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "path": cloudAgentSkillEntryPath, + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "path": "", + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "path": "cards/a.md", + }), + } + usage := cloudAgentSkillUsageFromEvents(events) + if len(usage.Skills) != 1 { + t.Fatalf("skills = %d, want 1", len(usage.Skills)) + } + got := usage.Skills[0] + if got.EntryReads != 2 { + t.Fatalf("entryReads = %d, want 2 (SKILL.md plus directory listing)", got.EntryReads) + } + if got.CardReads != 1 || got.ReadCalls != 3 { + t.Fatalf("cardReads/readCalls = %d/%d, want 1/3", got.CardReads, got.ReadCalls) + } +} + +func TestCloudAgentSkillUsageCountsReadFailures(t *testing.T) { + events := []CloudAgentEvent{ + cloudAgentUsageEvent("run-1", "tool_failed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "path": "cards/missing.md", + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "path": "cards/a.md", + }), + } + usage := cloudAgentSkillUsageFromEvents(events) + got := usage.Skills[0] + if got.ReadFailures != 1 { + t.Fatalf("readFailures = %d, want 1", got.ReadFailures) + } + if got.ReadCalls != 1 || got.CardReads != 1 { + t.Fatalf("readCalls/cardReads = %d/%d, want 1/1", got.ReadCalls, got.CardReads) + } + if len(got.TopCards) != 1 || got.TopCards[0].Path != "cards/a.md" { + t.Fatalf("topCards must only count successful reads, got %+v", got.TopCards) + } +} + +func TestCloudAgentSkillUsageTopCardsCappedAndOrdered(t *testing.T) { + // Six distinct cards with strictly decreasing counts, so the cap has an + // unambiguous coldest card to drop. + counts := []struct { + path string + count int + }{ + {"cards/a.md", 6}, + {"cards/b.md", 5}, + {"cards/c.md", 4}, + {"cards/d.md", 3}, + {"cards/e.md", 2}, + {"cards/f.md", 1}, + } + var events []CloudAgentEvent + total := 0 + for _, card := range counts { + for i := 0; i < card.count; i++ { + events = append(events, cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "path": card.path, + })) + } + total += card.count + } + usage := cloudAgentSkillUsageFromEvents(events) + got := usage.Skills[0] + if got.CardReads != total { + t.Fatalf("cardReads = %d, want %d", got.CardReads, total) + } + if len(got.TopCards) != cloudAgentSkillUsageTopCards { + t.Fatalf("topCards = %d, want cap %d", len(got.TopCards), cloudAgentSkillUsageTopCards) + } + // The cap keeps the hottest cards, so the lowest-count card is dropped. + for _, card := range got.TopCards { + if card.Path == "cards/f.md" { + t.Fatalf("topCards kept the coldest card: %+v", got.TopCards) + } + } + if got.TopCards[0].Path != "cards/a.md" { + t.Fatalf("topCards[0] = %s, want the hottest card", got.TopCards[0].Path) + } + for i := 1; i < len(got.TopCards); i++ { + if got.TopCards[i-1].Count < got.TopCards[i].Count { + t.Fatalf("topCards not ordered by count desc: %+v", got.TopCards) + } + } +} + +func TestCloudAgentSkillUsageIgnoresNonSkillToolsAndPayloads(t *testing.T) { + events := []CloudAgentEvent{ + cloudAgentUsageEvent("run-1", "tool_completed", "canvas_get_state", map[string]any{ + "toolName": "canvas_get_state", "result": map[string]any{"nodes": []any{}}, + }), + cloudAgentUsageEvent("run-1", "reasoning_message", "reasoning", map[string]any{"text": "skill_search skill_read_file"}), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "path": "cards/orphan.md", + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_search", map[string]any{ + "toolName": "skill_search", "result": map[string]any{"matches": []any{ + map[string]any{"skillId": "", "path": "SKILL.md"}, + "not-an-object", + }}, + }), + } + usage := cloudAgentSkillUsageFromEvents(events) + if len(usage.Skills) != 0 { + t.Fatalf("skills = %d, want 0 (reads without skillId are not attributable)", len(usage.Skills)) + } + // The search round itself is still counted even though nothing matched. + if usage.SearchCalls != 1 { + t.Fatalf("searchCalls = %d, want 1", usage.SearchCalls) + } + if usage.ScannedRuns != 0 { + t.Fatalf("scannedRuns = %d, want 0 without a skills_load event", usage.ScannedRuns) + } +} + +func TestCloudAgentSkillUsageEmptyJournal(t *testing.T) { + usage := cloudAgentSkillUsageFromEvents(nil) + if usage == nil { + t.Fatal("usage must never be nil") + } + if len(usage.Skills) != 0 || usage.ScannedRuns != 0 || usage.SearchCalls != 0 { + t.Fatalf("empty usage = %+v", usage) + } +} + +func TestCloudAgentSkillUsageSkillsLoadWithoutIDsFallsBackToRunCount(t *testing.T) { + // Journal rows written before the skillIds field existed still count the run. + events := []CloudAgentEvent{ + cloudAgentUsageEvent("run-1", "tool_completed", "skills_load", map[string]any{ + "toolName": "skills_load", "text": "已启用 3 个技能,正文将按需读取", + }), + cloudAgentUsageEvent("run-1", "tool_completed", "skill_read_file", map[string]any{ + "toolName": "skill_read_file", "skillId": "s1", "path": "cards/a.md", + }), + } + usage := cloudAgentSkillUsageFromEvents(events) + if usage.ScannedRuns != 1 { + t.Fatalf("scannedRuns = %d, want 1", usage.ScannedRuns) + } + if len(usage.Skills) != 1 || usage.Skills[0].RunsEnabled != 0 { + t.Fatalf("skills = %+v, want s1 with runsEnabled 0", usage.Skills) + } +} + +func TestCloudAgentSkillUsageReadsOnlyOwnJournalAndRejectsCorruptReceipts(t *testing.T) { + s, db, _, _ := creationTestService(t) + for _, item := range []struct { + id, userID, skillID string + }{ + {"own-usage-run", "user", "own-skill"}, + {"foreign-usage-run", "other", "foreign-skill"}, + } { + if err := db.Create(&model.CloudAgentExecution{ID: item.id, UserID: item.userID, Status: "completed", CreatedAt: time.Now()}).Error; err != nil { + t.Fatal(err) + } + body := `{"runId":"` + item.id + `","type":"tool_completed","payload":{"toolName":"skills_load","skillIds":["` + item.skillID + `"]}}` + if err := db.Create(&model.CloudAgentEventRecord{RunID: item.id, UserID: item.userID, Sequence: 1, EventJSON: body}).Error; err != nil { + t.Fatal(err) + } + } + usage, err := s.CloudAgentSkillUsage("user") + if err != nil { + t.Fatal(err) + } + if usage.ScannedRuns != 1 || len(usage.Skills) != 1 || usage.Skills[0].SkillID != "own-skill" { + t.Fatalf("cross-user journal leaked or own skill missing: %+v", usage) + } + if err := db.Model(&model.CloudAgentEventRecord{}).Where("run_id = ?", "own-usage-run").Update("event_json", "{").Error; err != nil { + t.Fatal(err) + } + if _, err := s.CloudAgentSkillUsage("user"); err == nil { + t.Fatal("corrupt receipt must not produce incomplete success metrics") + } +} diff --git a/backend/internal/handler/agent.go b/backend/internal/handler/agent.go index 8b7ff1cfe..5cdab45d8 100644 --- a/backend/internal/handler/agent.go +++ b/backend/internal/handler/agent.go @@ -24,6 +24,21 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) { capabilities := service.CloudAgentCapabilitySetInfo() ok(c, gin.H{"version": 2, "permissionModes": []string{"read_only", "request_approval", "auto"}, "contextScopes": []string{"canvas"}, "skills": true, "writeTools": true, "billing": "fixed_request", "maxHistoryPairs": 10, "maxHistoryBytes": 64000, "maxSteps": 0, "tools": service.CloudAgentSupportedToolNames(), "capabilitySetVersion": capabilities.Version, "capabilitySetHash": capabilities.Hash, "nodeTypes": capabilities.Nodes}) }) + // Skill usage is derived from the caller's own journal receipts, so it stays + // read-only and never exposes another user's runs. + r.GET("/agent/skills/usage", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + view, err := svc.CloudAgentSkillUsage(user.ID) + if err != nil { + failService(c, err) + return + } + ok(c, view) + }) // Profiles are durable preference data, not an authorization surface. The // service validates scope ownership and the compiler injects the effective // layers only after the code-level policy has been fixed. diff --git a/backend/internal/handler/api_test.go b/backend/internal/handler/api_test.go index 6925e98ed..96ce7a538 100644 --- a/backend/internal/handler/api_test.go +++ b/backend/internal/handler/api_test.go @@ -23,6 +23,7 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) { "POST /api/tasks/:id/recover-media": false, "GET /api/resources": false, "GET /api/skills/presets": false, + "GET /api/agent/skills/usage": false, } for _, route := range router.Routes() { key := route.Method + " " + route.Path diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 754d26af6..022b4aa4f 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -412,6 +412,15 @@ paths: responses: "200": description: 信封 data 包含 version、permissionModes、contextScopes、skills、writeTools、billing、maxHistoryPairs、maxSteps、maxHistoryBytes、capabilitySetVersion、capabilitySetHash、nodeTypes、tools;maxHistoryPairs 为跨轮保留的用户对话轮数,maxSteps 为 0 表示无固定模型调用上限,历史仍受 maxHistoryBytes 限制 + /agent/skills/usage: + get: + tags: [agent] + summary: 读取当前用户技能的使用统计(由运行日志聚合,只读) + responses: + "200": + description: 信封 data 含 scannedRuns、searchCalls 与按启用次数降序的 skills;每个技能含 runsEnabled、searchHits、readCalls、readFailures、entryReads、cardReads 与 topCards(最多 5 条最热卡路径)。统计仅覆盖调用方本人最近的运行,且不含任何技能正文 + "401": + description: 未登录 /agent/profile: get: tags: [agent] diff --git a/backend/internal/repository/cloud_agent.go b/backend/internal/repository/cloud_agent.go index 30cdd42e9..15f416ab7 100644 --- a/backend/internal/repository/cloud_agent.go +++ b/backend/internal/repository/cloud_agent.go @@ -93,6 +93,31 @@ func (r *Repository) CloudAgentRevision(userID, id string) (int64, error) { return run.Revision, err } +// RecentCloudAgentEventsForUser returns the journal rows of the caller's most +// recent runs, ordered by event time. Runs are resolved first so a fixed +// event limit cannot cut a run in half; an ongoing run may still add events. +func (r *Repository) RecentCloudAgentEventsForUser(userID string, runLimit int) ([]model.CloudAgentEventRecord, error) { + if runLimit < 1 { + return nil, nil + } + var runIDs []string + if err := r.db.Model(&model.CloudAgentExecution{}). + Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(runLimit). + Pluck("id", &runIDs).Error; err != nil { + return nil, err + } + if len(runIDs) == 0 { + return nil, nil + } + var records []model.CloudAgentEventRecord + err := r.db.Where("user_id = ? AND run_id IN ?", userID, runIDs). + Order("created_at, sequence"). + Find(&records).Error + return records, err +} + // Lock before reading: checkpoints, canvas writes and task reservations commit together. func (r *Repository) MutateCloudAgent(userID, id string, revision int64, fn func(*model.CloudAgentExecution, *Repository) error) error { return r.db.Transaction(func(tx *gorm.DB) error { diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 3b772ca70..0a0fb5b0f 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -207,6 +207,7 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 - 必须有当前用户已保存的画布。首轮摘要最多取前 80 个节点,并按约 60KB 预算截断;装不下的节点记入 `omittedNodes`,不因摘要过大整轮拒绝。`canvas_get_state` 可按 `offset` 每页读取 40 个节点、资产可引用状态及相关连线,按 `nodeIds` 精读最多 8 个节点(正文每字段最多 16000 字符),通过 `hasMore` / `nextOffset` 和字段 `Truncated` 判断是否完整。分镜表包含镜头、角色节点与资产绑定;摘要每页最多 20 行,精读每次 1 行,可用 `storyboardOffset` 翻页;单行叙事文本累计最多 24000 字符,角色 / 绑定各最多 16 项。上下文不含媒体 URL、任意元数据或未同步修改;节点名称不等于图像视觉识别。普通任务重试接口不能重试 Agent 任务。 - `canvas_get_state` 首次调用传 `{}`,仅接受可选的 `offset`、`storyboardOffset`(非负整数)和 `nodeIds`(最多 8 个字符串组成的数组);当前画布由运行绑定,不接受 `canvasId`、`limit` 或 `page`。读取工具分别反馈非对象 / 多对象、JSON 格式错误、未知字段和字段类型错误。可纠正的工具参数失败在 `tool_failed.result` 及模型工具结果中返回 `reason: invalid_tool_arguments`、本轮已声明的 `parameters` 和 `guidance`;画布读取额外给出 `exampleArguments: {}`。失败调用仍如实记录,由模型修正参数后继续,不静默丢弃字段或自动重放写操作;资源归属、审批、快照和收费校验仍按原规则执行。 - `GET /agent/runs/:id` 返回运行信息;`POST /agent/runs/:id/cancel` 取消当前运行及活动子任务;`POST /agent/runs/:id/approvals/:approvalId/decision` 接受 `{"decision":"approve"|"reject","reason":"..."}`,审批决定幂等且服务端校验归属。 +- `GET /agent/skills/usage` 需要登录,只聚合当前用户最近最多 200 次 Agent 运行的日志;返回技能启用次数、检索命中、成功/失败读取、总纲/卡片读取和最多 5 条最常读卡片路径,不返回技能正文。损坏的日志会返回错误而非静默给出不完整统计。 - 首次通过取消接口取消运行时记录一次 `run_cancelled` 事件,`source: user_request` 表示来自用户取消 API,并携带当时的 `activeTaskId` / `mediaTaskId`;此事件表示取消请求已接收,关联任务最终状态以任务查询及收尾状态为准。运行记录损坏或超限时优先保证取消和后台收尾,事件无法保存会记录服务端告警;旧导出日志不能据此追溯取消发起来源。 - `GET /agent/runs/:id/events` 返回 `agent_event` 和首次 / 状态版本变化时的紧凑 `run_snapshot` SSE;无变化时只发送 heartbeat。事件 `Seq` 在运行内递增;支持 `?after=` / `Last-Event-ID`,游标不是运行 ID。`cleanupPending: true` 时即使运行已进入终态也会继续推送,直到后台收尾完成。前端应以快照恢复状态,代理只对这一精确路径关闭缓冲。 - `generation_task_created` 携带 `canvasId`、`nodeId`、`taskId`、`title`、`mode`、`referenceNodeIds`。草稿、提交、画布工具写入,以及媒体成功 / 失败 / 取消的真实节点变更,均在画布写入与运行检查点的同一事务持久化 `canvas_updated`。节点已删除或换任务时不伪造更新;`run_failed` 本身不代表节点媒体任务失败。