feat(agent): 技能用量 - 按用户运行日志统计检索与读取 (#590)

This commit is contained in:
itsWyatt-K
2026-09-23 16:22:44 +08:00
committed by ddCat
parent 67cbda6cd6
commit 33baecde28
8 changed files with 514 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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]

View File

@@ -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 {

View File

@@ -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` 本身不代表节点媒体任务失败。