From f170649e9d3f02201907450941b3b43f886fbe07 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:20:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E4=BA=91=E7=AB=AF=20Agent=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=20-=20=E5=AE=8C=E5=96=84=20Pi=20?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E3=80=81=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8E=8B?= =?UTF-8?q?=E5=8A=9B=E4=B8=8E=E4=B8=B4=E6=97=B6=E6=95=85=E9=9A=9C=E9=87=8D?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/agent-runtime/pi/agent-runtime.mjs | 90 ++- backend/internal/app/cloud_agent.go | 4 - .../cloud_agent_context_compaction_test.go | 9 - .../app/cloud_agent_context_pressure.go | 296 -------- .../app/cloud_agent_context_pressure_test.go | 689 ------------------ .../internal/app/cloud_agent_context_test.go | 45 ++ .../app/cloud_agent_continuation_test.go | 6 +- backend/internal/app/cloud_agent_lessons.go | 20 - backend/internal/app/cloud_agent_media.go | 133 ++-- .../internal/app/cloud_agent_media_test.go | 63 ++ backend/internal/app/cloud_agent_pi_bridge.go | 339 ++++++++- .../internal/app/cloud_agent_pi_helpers.go | 19 +- backend/internal/app/cloud_agent_pi_model.go | 248 ++++++- .../internal/app/cloud_agent_pi_model_test.go | 277 +++++++ .../internal/app/cloud_agent_pi_request.go | 8 +- backend/internal/app/cloud_agent_policy.go | 52 -- .../app/cloud_agent_prompt_context_test.go | 6 +- backend/internal/app/cloud_agent_runtime.go | 42 +- .../internal/app/cloud_agent_runtime_media.go | 43 +- .../app/cloud_agent_runtime_messages_test.go | 74 +- .../app/cloud_agent_runtime_scheduler.go | 3 - .../cloud_agent_runtime_tool_result_test.go | 49 +- .../internal/app/cloud_agent_runtime_tools.go | 111 +++ .../app/cloud_agent_tool_error_class.go | 7 +- .../app/cloud_agent_tool_error_class_test.go | 52 ++ .../internal/app/cloud_agent_tool_repair.go | 8 +- .../internal/app/cloud_agent_usage_anchor.go | 159 ---- .../internal/app/provider_agent_protocol.go | 215 +++++- backend/internal/app/provider_test.go | 38 +- backend/internal/app/provider_text_agent.go | 7 +- .../app/provider_text_agent_stream.go | 18 +- backend/internal/repository/analytics.go | 18 +- 32 files changed, 1663 insertions(+), 1485 deletions(-) delete mode 100644 backend/internal/app/cloud_agent_context_pressure.go delete mode 100644 backend/internal/app/cloud_agent_context_pressure_test.go create mode 100644 backend/internal/app/cloud_agent_pi_model_test.go delete mode 100644 backend/internal/app/cloud_agent_usage_anchor.go diff --git a/backend/agent-runtime/pi/agent-runtime.mjs b/backend/agent-runtime/pi/agent-runtime.mjs index 4465f85c3..7a98238e9 100644 --- a/backend/agent-runtime/pi/agent-runtime.mjs +++ b/backend/agent-runtime/pi/agent-runtime.mjs @@ -18,6 +18,7 @@ setGlobalDispatcher(new Agent({ const providerID = "infinite-canvas"; const api = "openai-completions"; + async function readRequest() { let raw = ""; for await (const chunk of process.stdin) raw += chunk; @@ -36,6 +37,7 @@ function createMessage(model, result) { content.push({ type: "toolCall", id: call.id, + ...(call.item_id ? { itemId: call.item_id } : {}), name: call.name, arguments: call.arguments ?? {}, }); @@ -96,6 +98,12 @@ async function run() { const sessionDir = join(isolatedDir, "sessions"); await mkdir(workDir, { mode: 0o700 }); await mkdir(sessionDir, { mode: 0o700 }); + // Lifecycle state, not prompt wording, identifies SDK summarization calls. + // Keep the id through the closing event delivery so the requested/end pair + // can always be correlated, while the closing phase no longer labels a + // following normal model request as a summary request. + let activeCompaction = null; + const eventChain = { current: Promise.resolve() }; const modelRuntime = await ModelRuntime.create({ authPath: join(isolatedDir, "auth.json"), modelsPath: join(isolatedDir, "models.json"), @@ -131,8 +139,14 @@ async function run() { void (async () => { stream.push({ type: "start", partial }); try { + const purpose = activeCompaction?.phase === "summarizing" ? "compaction" : "conversation"; + // Deliver lifecycle events/snapshots before scheduling the next request. + // In particular, compaction must be visible before its model call begins. + await eventChain.current; const result = await bridge(request, "/model", { modelId: model.id, + purpose, + systemPrompt: context.systemPrompt, messages: context.messages, tools: (context.tools ?? []).map((tool) => ({ name: tool.name, @@ -140,6 +154,7 @@ async function run() { parameters: tool.parameters, })), thinkingLevel: options?.reasoning ?? "off", + contextUsage: session.getContextUsage(), }, options?.signal); for (const text of result.steeringMessages ?? []) { await session.steer(text); @@ -308,13 +323,35 @@ async function run() { retry: { enabled: false }, }); - const eventChain = { current: Promise.resolve() }; let runtimeError = null; // 事件链串行执行。已有一次快照在排队时,它执行时读到的已是最新文件, // 不必为每条新条目再整份上传一次。 let snapshotQueued = false; const enqueueEvent = (payload) => { - eventChain.current = eventChain.current.then(() => bridge(request, "/event", payload)); + const pending = eventChain.current.then(() => bridge(request, "/event", payload)); + eventChain.current = pending; + return pending; + }; + const enqueueSessionSnapshot = () => { + if (snapshotQueued) return; + snapshotQueued = true; + eventChain.current = eventChain.current.then(async () => { + snapshotQueued = false; + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) return; + let sessionJSONL; + try { + sessionJSONL = await readFile(sessionFile, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + return bridge(request, "/event", { + type: "session_snapshot", + sessionJSONL, + contextUsage: session.getContextUsage(), + }); + }); }; session.subscribe((event) => { @@ -418,27 +455,36 @@ async function run() { approvalId: event.approvalId, }); } else if (event.type === "entry_appended") { - if (snapshotQueued) return; - snapshotQueued = true; - eventChain.current = eventChain.current.then(async () => { - snapshotQueued = false; - const sessionFile = sessionManager.getSessionFile(); - if (!sessionFile) return; - let sessionJSONL; - try { - sessionJSONL = await readFile(sessionFile, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") return; - throw error; - } - return bridge(request, "/event", { - type: "session_snapshot", - sessionJSONL, - contextUsage: session.getContextUsage(), - }); - }); + enqueueSessionSnapshot(); } else if (event.type === "compaction_start" || event.type === "compaction_end") { - enqueueEvent({ type: event.type, reason: event.reason, contextUsage: session.getContextUsage() }); + // Persist the compaction entry before reporting it to the server. If event + // delivery fails, the summary and retained tail are still recoverable. + if (event.type === "compaction_start") activeCompaction = { id: crypto.randomUUID(), phase: "summarizing" }; + const compaction = activeCompaction; + if (event.type === "compaction_end" && compaction) compaction.phase = "closing"; + if (event.type === "compaction_end") enqueueSessionSnapshot(); + const eventDelivery = enqueueEvent({ + type: event.type, + compactionId: compaction?.id, + reason: event.reason, + aborted: event.aborted, + willRetry: event.willRetry, + errorMessage: event.errorMessage, + hasResult: Boolean(event.result), + tokensBefore: event.result?.tokensBefore, + estimatedTokensAfter: event.result?.estimatedTokensAfter, + contextUsage: session.getContextUsage(), + }); + if (event.type === "compaction_end" && compaction) { + eventDelivery.then( + () => { + if (activeCompaction === compaction) activeCompaction = null; + }, + () => { + if (activeCompaction === compaction) activeCompaction = null; + }, + ); + } } }); diff --git a/backend/internal/app/cloud_agent.go b/backend/internal/app/cloud_agent.go index 590fced39..fe49a2a2d 100644 --- a/backend/internal/app/cloud_agent.go +++ b/backend/internal/app/cloud_agent.go @@ -494,10 +494,6 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare canonical.Messages = append(append([]map[string]any{}, canonical.Messages[:len(canonical.Messages)-1]...), catalog, last) } s.attachCloudAgentLessons(&canonical, userID, req.Prompt) - // 必须登记进 state.Policy(值拷贝):state 才是随任务持久化、被运行期读取的那份, - // 在这里改局部 policy 不会生效。登记之后压力读数的"系统提示分段"才能把 memory 摊开, - // 并与 system 桶合计对齐。 - cloudAgentRecordMemorySegment(&state.Policy, canonical.SystemPrompt) canonical.PromptCacheKey = cloudAgentPromptCacheKeyForRequest(req.CanvasID, cloudAgentPromptCacheIdentity(req, policy), canonical.SystemPrompt, canonical.Tools) attachCloudAgentPlan(&canonical, inheritedPlan) input := map[string]any{"mode": "text", "prompt": req.Prompt, "textHistory": history, "textOptions": map[string]any{"stream": true, "thinking": cloudAgentReasoningEnabled(policy.ReasoningMode)}, "cloudAgent": state, diff --git a/backend/internal/app/cloud_agent_context_compaction_test.go b/backend/internal/app/cloud_agent_context_compaction_test.go index 1d133afea..ee6b347aa 100644 --- a/backend/internal/app/cloud_agent_context_compaction_test.go +++ b/backend/internal/app/cloud_agent_context_compaction_test.go @@ -208,11 +208,8 @@ func TestCloudAgentCompactionResumesStepLoopWithCheckpoint(t *testing.T) { map[string]any{"role": "user", "content": "把第 4 行补上光影"}, map[string]any{"role": "assistant", "content": "好的,先看一下现有配色"}, ) - lastTaskID, lastEstimate := state.LastStepTaskID, state.LastStepEstimate - state.TokenAnchor = &cloudAgentTokenAnchor{TaskID: lastTaskID, Step: state.Step, InputTokens: 10000, EstimatedTokens: lastEstimate, Accepted: true} run = saveCloudAgentCompactionState(t, s, run, &state) stepBefore := state.Step - pressureCount := len(cloudAgentCompactionEvents(t, s, run, "context_pressure")) if requested, err := s.cloudAgentRequestCompaction(run, &state, budget, budget.CompactAtTokens); err != nil || !requested { t.Fatalf("没有触发压缩: %v", err) } @@ -227,12 +224,6 @@ func TestCloudAgentCompactionResumesStepLoopWithCheckpoint(t *testing.T) { if persisted.Step != stepBefore { t.Fatalf("压缩调用占掉了步数: %d → %d", stepBefore, persisted.Step) } - if persisted.LastStepTaskID != lastTaskID || persisted.LastStepEstimate != lastEstimate || persisted.TokenAnchor == nil || !persisted.TokenAnchor.Accepted { - t.Fatalf("压缩调用不得覆盖普通模型步骤和锚点: last=%s estimate=%d anchor=%+v", persisted.LastStepTaskID, persisted.LastStepEstimate, persisted.TokenAnchor) - } - if got := len(cloudAgentCompactionEvents(t, s, run, "context_pressure")); got != pressureCount { - t.Fatalf("压缩任务不得发普通模型请求读数: %d → %d", pressureCount, got) - } task, err := s.repo.TaskForUser("user", persisted.ActiveTaskID) if err != nil { t.Fatal(err) diff --git a/backend/internal/app/cloud_agent_context_pressure.go b/backend/internal/app/cloud_agent_context_pressure.go deleted file mode 100644 index ddd7aa904..000000000 --- a/backend/internal/app/cloud_agent_context_pressure.go +++ /dev/null @@ -1,296 +0,0 @@ -package app - -import ( - "encoding/json" - "math" - "unicode" - "unicode/utf8" -) - -// cloudAgentContextPressure 是"这次请求还能装多少输入"的读数。 -// 消费方(上下文计量弹窗、运行诊断)直接读这份结构,不自己再算一遍。 -type cloudAgentContextPressure struct { - EstimatedInputTokens int `json:"estimatedInputTokens"` - ContextWindowTokens int `json:"contextWindowTokens"` - ReservedOutputTokens int `json:"reservedOutputTokens"` - UsableInputTokens int `json:"usableInputTokens"` - PressureRatio float64 `json:"pressureRatio"` - SourceBytes int `json:"sourceBytes"` - PromptChars int `json:"promptChars"` - PromptLimitChars int `json:"promptLimitChars"` - ModelLimitConfigured bool `json:"modelLimitConfigured"` - Estimate bool `json:"estimate"` - // 三个窗口口径字段说明"这条读数按哪个算式算出来":OverheadTokens 是工具 schema / - // 协议包装 / 兜底轮的比例预留,InputBudgetTokens 才是真正可供输入使用的额度。 - OverheadTokens int `json:"overheadTokens,omitempty"` - InputBudgetTokens int `json:"inputBudgetTokens,omitempty"` - BudgetSource string `json:"budgetSource,omitempty"` - // CompactAtTokens 是统一的上下文压缩线(模型窗口的 80%,且不超过输入准入预算,见 - // compactCloudAgentContext)。它只是给读数一个参照刻度,不是本读数触发的动作。 - CompactAtTokens int `json:"compactAtTokens,omitempty"` -} - -// cloudAgentProjectedInputTokens 合成"下一步输入 token"的权威读数: -// provider 锚点(模型自己的分词器读数)+ 本地估算的有符号增量;没有可用锚点时退回纯估算。 -// 增量带符号是刻意的:上下文被裁剪(图片移出、正文卸载)后本地估算会变小, -// 投影也必须跟着变小,否则读数只会单调上涨。 -func cloudAgentProjectedInputTokens(pressure cloudAgentContextPressure, state *cloudAgentRuntime) (int, string) { - if state == nil || state.TokenAnchor == nil || !state.TokenAnchor.Accepted { - return pressure.EstimatedInputTokens, "estimate" - } - anchor := state.TokenAnchor - delta := pressure.EstimatedInputTokens - anchor.EstimatedTokens - return max(0, int(anchor.InputTokens)+delta), "provider" -} - -// cloudAgentContextPressurePayload 把读数摊成事件载荷。 -// -// 两条硬规则(消费方不必猜): -// 1. 两个量纲各自命名、互不覆盖:本地估算描述**下一次**请求(readingScope=next_request / -// estimateMethod=local_v1),上游实测量的是**上一次**请求(providerMeasurementScope= -// previous_request),两者不能画成同一条曲线。 -// 2. 没有解析到模型自己声明的窗口时不下发窗口字段与占用率:宁可不给百分比, -// 也不要拿兜底默认窗口编一个看起来很像真的"已占用 80%"。 -func cloudAgentContextPressurePayload(pressure cloudAgentContextPressure, state *cloudAgentRuntime, actual ...canonicalAgentRequest) map[string]any { - payload := map[string]any{ - "estimatedInputTokens": pressure.EstimatedInputTokens, - "sourceBytes": pressure.SourceBytes, - "promptChars": pressure.PromptChars, - "estimate": pressure.Estimate, - // provider usage measures the previous request; the estimate describes - // the next request. Consumers must not draw them as one series. - "readingScope": "next_request", "estimateMethod": "local_v1", - // 快照版本与相位:读数属于"下一次请求发出之前"的测量。 - "schemaVersion": 2, "phase": "before_request", - } - if pressure.PromptLimitChars > 0 { - payload["promptLimitChars"] = pressure.PromptLimitChars - } - payload["modelLimitConfigured"] = pressure.ModelLimitConfigured - if pressure.ModelLimitConfigured { - payload["contextWindowTokens"] = pressure.ContextWindowTokens - payload["reservedOutputTokens"] = pressure.ReservedOutputTokens - payload["usableInputTokens"] = pressure.UsableInputTokens - payload["inputBudgetTokens"] = pressure.InputBudgetTokens - payload["overheadTokens"] = pressure.OverheadTokens - payload["compactAtTokens"] = pressure.CompactAtTokens - payload["budgetSource"] = firstNonEmpty(pressure.BudgetSource, "channel-model") - if pressure.UsableInputTokens > 0 { - payload["pressureRatio"] = pressure.PressureRatio - } - } - if state == nil { - return payload - } - // 上游实测锚点:provider 用模型自己的分词器报出的 prompt 规模,是权威读数。 - projectedTokens, tokenSource := cloudAgentProjectedInputTokens(pressure, state) - if anchor := state.TokenAnchor; anchor != nil { - payload["anchorStep"] = anchor.Step - payload["anchor"] = map[string]any{ - "id": anchor.TaskID, "valid": anchor.Accepted, "ageSteps": max(0, state.Step-anchor.Step), - } - if anchor.Accepted { - payload["pressureTokens"] = anchor.InputTokens - payload["providerMeasurementScope"] = "previous_request" - payload["providerUsage"] = map[string]any{ - "inputTokens": anchor.InputTokens, "cacheReadTokens": anchor.CachedTokens, - "uncachedInputTokens": max(0, anchor.InputTokens-anchor.CachedTokens), - "outputTokens": anchor.OutputTokens, - } - // anchorDeltaTokens 是"本地估算相对锚点那一步的增量",与投影的分母无关: - // 消费方拿它解释"较锚点 +N",不能改成"投影 − 估算"。 - payload["anchorDeltaTokens"] = pressure.EstimatedInputTokens - anchor.EstimatedTokens - if anchor.EstimatedTokens > 0 { - payload["tokenScale"] = math.Round(float64(anchor.InputTokens)/float64(anchor.EstimatedTokens)*10000) / 10000 - } - tokenSource = "provider" - } else if anchor.RejectReason != "" { - payload["anchorRejected"] = anchor.RejectReason - } - } - payload["projectedTokens"] = projectedTokens - payload["tokenSource"] = tokenSource - // 两种量纲各自命名,消费方不需要靠 tokenSource 猜哪个数字属于哪把尺子。 - payload["measurementSource"] = tokenSource - payload["normalizedInputTokens"] = pressure.EstimatedInputTokens - if tokenSource == "provider" { - payload["normalizedInputTokens"] = state.TokenAnchor.InputTokens - } - payload["projectedNextInputTokens"] = projectedTokens - if pressure.ContextWindowTokens > 0 && tokenSource == "provider" { - payload["projectedPressureRatio"] = math.Min(9.99, float64(projectedTokens)/float64(pressure.ContextWindowTokens)) - } - payload["breakdown"] = cloudAgentContextBreakdownPayload(state, actual...) - return payload -} - -// cloudAgentContextBreakdownPayload 报"这次要发出去的请求被谁占着":三个 canonical 桶 -// 加上编译进系统提示的各分段,用的是与占用读数同一把估算尺子。 -// 桶合计与 canonical 总量的差是协议外壳(tool choice、缓存键),单独报而不摊进任何一个桶。 -func cloudAgentContextBreakdownPayload(state *cloudAgentRuntime, actual ...canonicalAgentRequest) map[string]any { - canonical := state.Canonical - if len(actual) > 0 { - canonical = actual[0] - } - system := []byte(canonical.SystemPrompt) - tools, _ := json.Marshal(canonical.Tools) - messages, _ := json.Marshal(canonical.Messages) - whole, _ := json.Marshal(canonical) - systemTokens, toolTokens, messageTokens := estimateCloudAgentTokens(system), estimateCloudAgentTokens(tools), estimateCloudAgentTokens(messages) - // 构成永远由本地估算给出;已有上游实测锚点时再给一份按锚点比例换算的读数, - // 让展示口径与 provider 的计数同尺度(原值不覆盖)。 - scale := 1.0 - if anchor := state.TokenAnchor; anchor != nil && anchor.Accepted && anchor.EstimatedTokens > 0 { - scale = float64(anchor.InputTokens) / float64(anchor.EstimatedTokens) - } - scaled := func(tokens int) int { return int(math.Round(float64(tokens) * scale)) } - var systemSegments []cloudAgentContextSegment - if segments := state.Policy.SystemSegments; len(segments) > 0 { - // 分段必须把 system 桶填满:编译之外拼接的块(个人记忆)由 - // cloudAgentRecordMemorySegment 登记,剩余零头(标题、分隔与拼接文本)归入"其它"。 - // 否则分段合计会小于 system 桶,看起来像少算了一截。 - systemSegments = append([]cloudAgentContextSegment{}, segments...) - accountedBytes, accountedTokens := 0, 0 - for _, segment := range systemSegments { - accountedBytes += segment.Bytes - accountedTokens += segment.Tokens - } - if remainder := len(system) - accountedBytes; remainder > 0 { - systemSegments = append(systemSegments, cloudAgentContextSegment{ - Key: "other", Label: "其它(标题与拼接)", Bytes: remainder, - Tokens: max(0, systemTokens-accountedTokens), - }) - } - // 逐段向上取整不满足可加性(Σ⌈ascii/4⌉ ≥ ⌈Σascii/4⌉),所以 system 桶的 token - // 口径直接取分段合计:桶与分段是同一份分解,消费方会拿分段算占比, - // 两边差几 token 就会显示成"分段占了 101%"。 - systemTokens = 0 - for index := range systemSegments { - systemSegments[index].ScaledTokens = scaled(systemSegments[index].Tokens) - systemTokens += systemSegments[index].Tokens - } - } - buckets := []map[string]any{ - {"key": "system", "label": "系统提示(含画布摘要)", "bytes": len(system), "tokens": systemTokens, "scaledTokens": scaled(systemTokens)}, - {"key": "tools", "label": "工具 schema", "bytes": len(tools), "tokens": toolTokens, "scaledTokens": scaled(toolTokens)}, - {"key": "messages", "label": "会话消息(含工具结果)", "bytes": len(messages), "tokens": messageTokens, "scaledTokens": scaled(messageTokens)}, - } - bucketBytes := len(system) + len(tools) + len(messages) - breakdown := map[string]any{ - "totalBytes": len(whole), "totalTokens": estimateCloudAgentTokens(whole), - "bucketBytes": bucketBytes, "bucketTokens": systemTokens + toolTokens + messageTokens, - "envelopeBytes": max(0, len(whole)-bucketBytes), - "buckets": buckets, - "tokenScale": math.Round(scale*10000) / 10000, - "scaledTotalTokens": scaled(estimateCloudAgentTokens(whole)), - } - if len(systemSegments) > 0 { - breakdown["systemSegments"] = systemSegments - } - return breakdown -} - -// estimateCloudAgentTokens 是计量用的本地估算:ASCII 按 4 字节 1 token,CJK 等非 ASCII -// 按 1 字符 1 token 计,数值上刻意保守(CJK 创作内容偏多)。 -// -// 它与同文件的 cloudAgentEstimatedTokens 不是同一把尺子,也不要合并:后者是 -// **请求准入**的判据(非 ASCII 按 1.5 token/字符,宁可高估也不能把装不下的请求放出去), -// 这里是**读数与锚点**的基准——锚点的采信区间(0.5×–2×)与 tokenScale 都是照着这把尺子 -// 标定的,换尺子等于把标定作废。它只是压力展示,既不是计费 token 数, -// 也不能替代上游 tokenizer。 -func estimateCloudAgentTokens(value []byte) int { - if len(value) == 0 { - return 0 - } - asciiUnits, nonASCII := 0, 0 - for len(value) > 0 { - r, size := utf8.DecodeRune(value) - if r == utf8.RuneError && size == 1 { - asciiUnits++ - value = value[1:] - continue - } - value = value[size:] - if r <= unicode.MaxASCII { - asciiUnits++ - } else { - nonASCII++ - } - } - return int(math.Ceil(float64(asciiUnits)/4.0)) + nonASCII -} - -// cloudAgentContextPressure 解析"这次请求能用多少输入"。 -// -// 没有解析到真实窗口时 UsableInputTokens 保持 0 且 ModelLimitConfigured=false, -// 消费方据此显示"未配置模型上限"而不是编一个占用率。 -func (s *Service) cloudAgentContextPressure(canonical canonicalAgentRequest, prompt string, request CloudAgentRequest) cloudAgentContextPressure { - raw, _ := json.Marshal(canonical) - pressure := cloudAgentContextPressure{ - EstimatedInputTokens: estimateCloudAgentTokens(raw), - SourceBytes: len(raw), - PromptChars: utf8.RuneCountInString(prompt), - Estimate: true, - } - // 提示词字符上限只来自渠道模型能力(逻辑模型没有单一渠道模型,保持 0 = 不声明)。 - if text := s.cloudAgentChannelTextCapability(request); text != nil { - pressure.PromptLimitChars = text.References.PromptMaxChars - } - budget := s.cloudAgentContextBudgetForRequest(request) - if !budget.Configured { - return pressure - } - pressure.ModelLimitConfigured = true - pressure.ContextWindowTokens = budget.ContextWindowTokens - pressure.ReservedOutputTokens = budget.MaxOutputTokens - pressure.OverheadTokens = budget.OverheadTokens - pressure.InputBudgetTokens = budget.InputBudgetTokens - pressure.CompactAtTokens = budget.CompactAtTokens - pressure.BudgetSource = budget.Source - // 展示口径与请求准入判据共用同一份输入预算:否则会出现"界面 79%、后台按 82% 拒绝"。 - pressure.UsableInputTokens = budget.InputBudgetTokens - if pressure.ContextWindowTokens > 0 { - pressure.PressureRatio = math.Min(9.99, float64(pressure.EstimatedInputTokens)/float64(pressure.ContextWindowTokens)) - } - return pressure -} - -// canonicalAgentRequestFromInput 从已落库的任务输入里取回"实际发出去的那份 canonical": -// 压力读数与锚点都必须对着真实信封,而不是内存里另算一份可能与准入改写结果不同的副本。 -func canonicalAgentRequestFromInput(input map[string]any) (canonicalAgentRequest, bool) { - requests, ok := input["agentRequests"].(map[string]any) - if !ok { - return canonicalAgentRequest{}, false - } - if requests["canonical"] == nil { - return canonicalAgentRequest{}, false - } - raw, err := json.Marshal(requests["canonical"]) - if err != nil { - return canonicalAgentRequest{}, false - } - var canonical canonicalAgentRequest - if json.Unmarshal(raw, &canonical) != nil { - return canonical, false - } - return canonical, true -} - -// cloudAgentNoteContextWindowResolved 在"窗口从未确认变为已确认"时落一条 context_transition。 -// -// 这一步只在读数口径变化时发生(例如早先拿不到模型能力、后来解析到了窗口):真实上下文 -// 没有变小,消费方必须能把它标成"模型窗口已识别",否则一识别就被画成上下文骤降。 -func cloudAgentNoteContextWindowResolved(runID string, state *cloudAgentRuntime, pressure cloudAgentContextPressure) { - if state == nil || runID == "" || !pressure.ModelLimitConfigured || state.ContextWindowKnown { - return - } - state.ContextWindowKnown = true - state.event(runID, "context_transition", map[string]any{ - "kind": "window_resolved", "reason": "window_resolved", - "after": map[string]any{ - "contextWindowTokens": pressure.ContextWindowTokens, "usableInputTokens": pressure.UsableInputTokens, - "source": firstNonEmpty(pressure.BudgetSource, "channel-model"), - }, - "text": "模型窗口已识别:读数改用窗口口径,这不是上下文变少", - }) -} diff --git a/backend/internal/app/cloud_agent_context_pressure_test.go b/backend/internal/app/cloud_agent_context_pressure_test.go deleted file mode 100644 index 8cc855a1b..000000000 --- a/backend/internal/app/cloud_agent_context_pressure_test.go +++ /dev/null @@ -1,689 +0,0 @@ -package app - -import ( - "encoding/json" - "strings" - "testing" - - "infinite-canvas/backend/internal/model" -) - -// 计量用的估算尺子:ASCII 每 4 字节 1 token 向上取整,非 ASCII 每字符 1 token。 -func TestEstimateCloudAgentTokensTreatsCJKConservatively(t *testing.T) { - if got := estimateCloudAgentTokens([]byte("abcd中文")); got != 3 { - t.Fatalf("estimate = %d, want 3", got) - } - if got := estimateCloudAgentTokens([]byte("hello")); got != 2 { - t.Fatalf("estimate = %d, want 2", got) - } - if got := estimateCloudAgentTokens(nil); got != 0 { - t.Fatalf("empty estimate = %d, want 0", got) - } -} - -// 两把尺子不混画:本地估算描述下一次请求,上游实测量的是上一次请求。 -func TestCloudAgentContextPressureSeparatesNextRequestEstimateFromPreviousProviderMeasurement(t *testing.T) { - pressure := cloudAgentContextPressure{EstimatedInputTokens: 20000, UsableInputTokens: 100000} - state := &cloudAgentRuntime{TokenAnchor: &cloudAgentTokenAnchor{ - Step: 2, InputTokens: 10500, EstimatedTokens: 19500, Accepted: true, - }} - payload := cloudAgentContextPressurePayload(pressure, state) - if payload["readingScope"] != "next_request" || payload["estimateMethod"] != "local_v1" { - t.Fatalf("next request scope missing: %+v", payload) - } - if payload["providerMeasurementScope"] != "previous_request" { - t.Fatalf("provider scope = %v", payload["providerMeasurementScope"]) - } - if payload["estimatedInputTokens"] != 20000 || payload["projectedTokens"] != 11000 { - t.Fatalf("estimate/projected readings were mixed: %+v", payload) - } -} - -// 快照字段:读数必须带版本、相位、测量来源、窗口与锚点状态,消费方不该靠字段名猜。 -func TestCloudAgentContextPressurePayloadCarriesSnapshotFields(t *testing.T) { - pressure := cloudAgentContextPressure{ - EstimatedInputTokens: 20000, UsableInputTokens: 100000, ContextWindowTokens: 128000, - ReservedOutputTokens: 16000, OverheadTokens: 5120, InputBudgetTokens: 100000, - CompactAtTokens: 85000, BudgetSource: "channel-model", ModelLimitConfigured: true, Estimate: true, - } - state := &cloudAgentRuntime{ - Step: 6, - TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-anchor", Step: 4, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, EstimatedTokens: 19500, Accepted: true}, - } - payload := cloudAgentContextPressurePayload(pressure, state) - if payload["schemaVersion"] != 2 || payload["phase"] != "before_request" { - t.Fatalf("snapshot version/phase 缺失:%+v", payload) - } - if payload["measurementSource"] != "provider" || payload["normalizedInputTokens"] != int64(10500) || payload["projectedNextInputTokens"] != 11000 { - t.Fatalf("测量来源与两种量纲没分开:%+v", payload) - } - if payload["contextWindowTokens"] != 128000 || payload["usableInputTokens"] != 100000 || - payload["reservedOutputTokens"] != 16000 || payload["overheadTokens"] != 5120 || - payload["inputBudgetTokens"] != 100000 || payload["compactAtTokens"] != 85000 || - payload["budgetSource"] != "channel-model" || payload["modelLimitConfigured"] != true { - t.Fatalf("窗口口径字段不齐:%+v", payload) - } - anchor, ok := payload["anchor"].(map[string]any) - if !ok || anchor["id"] != "task-anchor" || anchor["valid"] != true || anchor["ageSteps"] != 2 { - t.Fatalf("anchor 快照不对:%+v", payload["anchor"]) - } - usage, ok := payload["providerUsage"].(map[string]any) - if !ok || usage["cacheReadTokens"] != int64(2000) || usage["uncachedInputTokens"] != int64(8500) { - t.Fatalf("providerUsage 不对:%+v", payload["providerUsage"]) - } - if payload["tokenSource"] != "provider" || payload["tokenScale"] != 0.5385 { - t.Fatalf("tokenSource/tokenScale 不对:%+v", payload) - } -} - -// 窗口未配置时不假装有百分比:既不下发窗口字段,也不下发占用率,只留可用的本地估算。 -func TestCloudAgentContextPressureWithoutWindowDoesNotFakeRatio(t *testing.T) { - // 没有 repository:解析不到任何模型声明的窗口。 - service := &Service{} - canonical := canonicalAgentRequest{ - SystemPrompt: "系统提示", - Messages: []map[string]interface{}{{"role": "user", "content": "你好"}}, - } - pressure := service.cloudAgentContextPressure(canonical, "你好", CloudAgentRequest{}) - if pressure.ModelLimitConfigured || pressure.UsableInputTokens != 0 || pressure.PressureRatio != 0 { - t.Fatalf("未声明窗口时不该声称配置了模型上限:%+v", pressure) - } - payload := cloudAgentContextPressurePayload(pressure, &cloudAgentRuntime{}) - for _, key := range []string{ - "contextWindowTokens", "usableInputTokens", "reservedOutputTokens", "overheadTokens", - "inputBudgetTokens", "compactAtTokens", "budgetSource", "pressureRatio", "projectedPressureRatio", - } { - if _, exists := payload[key]; exists { - t.Fatalf("窗口未确认时不该下发 %s:%+v", key, payload) - } - } - if payload["modelLimitConfigured"] != false { - t.Fatalf("modelLimitConfigured = %v", payload["modelLimitConfigured"]) - } - if payload["tokenSource"] != "estimate" || payload["measurementSource"] != "estimate" { - t.Fatalf("没有实测时必须如实标注估算:%+v", payload) - } - if payload["estimatedInputTokens"] != pressure.EstimatedInputTokens || payload["normalizedInputTokens"] != pressure.EstimatedInputTokens { - t.Fatalf("估算读数缺失:%+v", payload) - } -} - -// 声明了窗口的渠道模型:窗口/输出预留/overhead/输入预算按同一算式给出,读数才有百分比。 -func TestCloudAgentContextPressureReportsWindowBudgetFromChannelCapability(t *testing.T) { - service, _, _, _ := creationTestService(t) - canonical := canonicalAgentRequest{ - SystemPrompt: "系统提示", - Messages: []map[string]interface{}{{"role": "user", "content": "分析剧情"}}, - } - pressure := service.cloudAgentContextPressure(canonical, "分析剧情", agentTestRequest()) - if !pressure.ModelLimitConfigured { - t.Fatalf("渠道模型声明了窗口,读数必须按窗口口径:%+v", pressure) - } - if pressure.ContextWindowTokens != defaultCloudAgentContextWindowTokens { - t.Fatalf("contextWindowTokens = %d", pressure.ContextWindowTokens) - } - // overhead = 窗口的 4% 夹在 4096–32768;输入预算 = 窗口 − 输出预留 − overhead。 - wantOverhead := defaultCloudAgentContextWindowTokens / 25 - if pressure.OverheadTokens != wantOverhead { - t.Fatalf("overheadTokens = %d, want %d", pressure.OverheadTokens, wantOverhead) - } - if pressure.InputBudgetTokens != pressure.ContextWindowTokens-defaultCloudAgentMaxOutputTokens-wantOverhead { - t.Fatalf("inputBudgetTokens = %d", pressure.InputBudgetTokens) - } - if pressure.UsableInputTokens != pressure.InputBudgetTokens || pressure.BudgetSource != "channel-model" { - t.Fatalf("预算来源/可用输入不一致:%+v", pressure) - } - payload := cloudAgentContextPressurePayload(pressure, &cloudAgentRuntime{}) - if payload["budgetSource"] != "channel-model" || payload["usableInputTokens"] != pressure.InputBudgetTokens { - t.Fatalf("载荷缺少窗口口径:%+v", payload) - } - if _, exists := payload["pressureRatio"]; !exists { - t.Fatalf("已确认窗口时必须给出占用率:%+v", payload) - } -} - -// 窗口从未确认变为已确认:要落且只落一条 context_transition。 -func TestCloudAgentNoteContextWindowResolvedEmitsOnce(t *testing.T) { - state := &cloudAgentRuntime{} - pressure := cloudAgentContextPressure{ModelLimitConfigured: true, ContextWindowTokens: 1000000, UsableInputTokens: 967232, BudgetSource: "channel-model"} - cloudAgentNoteContextWindowResolved("run-1", state, pressure) - cloudAgentNoteContextWindowResolved("run-1", state, pressure) - transitions := 0 - for _, event := range state.Events { - if event.Type == "context_transition" { - transitions++ - if event.Payload["kind"] != "window_resolved" { - t.Fatalf("过渡类型不对:%+v", event.Payload) - } - } - } - if transitions != 1 { - t.Fatalf("context_transition 条数 = %d,期望 1", transitions) - } - if !state.ContextWindowKnown { - t.Fatal("窗口识别标记没落下") - } -} - -// 第一步没有实测:读数是估算;上一步的 provider 用量到达后,第二步改用实测锚点, -// 并给出 tokenScale 与投影(锚点 + 本地有符号增量)。 -func TestCloudAgentContextPressureUsesEstimateOnFirstStepThenProviderAnchor(t *testing.T) { - service, db, _, _ := creationTestService(t) - request := agentTestRequest() - budget := service.cloudAgentContextBudgetForRequest(request) - if !budget.Configured { - t.Fatal("测试夹具的渠道模型应当声明了窗口") - } - state := &cloudAgentRuntime{Request: request, Step: 1, Canonical: canonicalAgentRequest{SystemPrompt: "系统提示"}} - - // 第一步:还没有任何上游实测,只能如实标注估算。 - first := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000, ModelLimitConfigured: true, UsableInputTokens: budget.InputBudgetTokens}, state) - if first["tokenSource"] != "estimate" || first["measurementSource"] != "estimate" { - t.Fatalf("第一步必须标估算:%+v", first) - } - if _, exists := first["tokenScale"]; exists { - t.Fatalf("没有锚点就不该有 tokenScale:%+v", first) - } - - // 上一步的模型调用回来了,上游上报了用量(qwen 系实测过本地估算偏高 ~18%)。 - if err := db.Create(&model.ApiCallLog{ - ID: "log-step-1", UserID: "user", TaskID: "task-step-1", Capability: "text", Model: "text-test", - Status: model.ApiCallStatusSucceeded, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, UsageAvailable: true, - }).Error; err != nil { - t.Fatal(err) - } - state.Step = 2 - state.LastStepTaskID = "task-step-1" - state.LastStepOperation = cloudAgentStepOperation - state.LastStepEstimate = 20000 - state.LastStepSourceBytes = 40000 - state.LastStepSignature = cloudAgentStepSignature(state) - service.recordCloudAgentTokenAnchor("user", state) - if state.TokenAnchor == nil || !state.TokenAnchor.Accepted { - t.Fatalf("合理的上游用量必须被采信:%+v", state.TokenAnchor) - } - - // 第二步:主读数换成"锚点实测 + 本地增量",并带上换算比例。 - second := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 22000, ModelLimitConfigured: true, UsableInputTokens: budget.InputBudgetTokens}, state) - if second["tokenSource"] != "provider" || second["measurementSource"] != "provider" { - t.Fatalf("拿到实测后主读数必须换成 provider:%+v", second) - } - if second["pressureTokens"] != int64(10500) || second["normalizedInputTokens"] != int64(10500) { - t.Fatalf("锚点实测值不对:%+v", second) - } - if second["projectedNextInputTokens"] != 12500 || second["projectedTokens"] != 12500 { - t.Fatalf("投影应为 10500 + (22000-20000):%+v", second) - } - if second["tokenScale"] != 0.525 || second["anchorDeltaTokens"] != 2000 { - t.Fatalf("换算比例/增量不对:%+v", second) - } - if second["estimatedInputTokens"] != 22000 { - t.Fatalf("本地估算不能被实测覆盖:%+v", second) - } -} - -// 锚点跨步沿用,但超过 3 步未刷新就作废(真机踩过"锚点冻结")。 -func TestCloudAgentTokenAnchorSurvivesWithinMaxAgeAndExpiresAfterwards(t *testing.T) { - state := &cloudAgentRuntime{Request: agentTestRequest(), Step: 2, TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-1", Step: 2, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000}} - state.TokenAnchor.Signature = cloudAgentStepSignature(state) - - state.Step = 2 + cloudAgentAnchorMaxAgeSteps - cloudAgentExpireTokenAnchor("run-1", state, 0) - if !state.TokenAnchor.Accepted || state.TokenAnchor.RejectReason != "" { - t.Fatalf("第 %d 步仍在有效期内,不该作废:%+v", cloudAgentAnchorMaxAgeSteps, state.TokenAnchor) - } - pressure := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) - if pressure["tokenSource"] != "provider" { - t.Fatalf("沿用中的锚点必须继续当主读数:%+v", pressure) - } - anchor, ok := pressure["anchor"].(map[string]any) - if !ok || anchor["valid"] != true || anchor["ageSteps"] != cloudAgentAnchorMaxAgeSteps { - t.Fatalf("anchor 快照不对:%+v", pressure["anchor"]) - } - - state.Step++ - cloudAgentExpireTokenAnchor("run-1", state, 0) - if state.TokenAnchor.Accepted || !strings.Contains(state.TokenAnchor.RejectReason, "未刷新") { - t.Fatalf("超期锚点必须作废:%+v", state.TokenAnchor) - } - pressure = cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) - if pressure["tokenSource"] != "estimate" || pressure["anchorRejected"] == nil { - t.Fatalf("作废后必须退回估算并给出原因:%+v", pressure) - } -} - -// 窗口变化后锚点作废:同一个 token 数在 8K 窗口和 256K 窗口下的含义完全不同。 -func TestCloudAgentTokenAnchorExpiresWhenWindowChanges(t *testing.T) { - state := &cloudAgentRuntime{ - Request: agentTestRequest(), Step: 4, - TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-1", Step: 1, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000, ContextWindowTokens: 128000}, - } - state.TokenAnchor.Signature = cloudAgentStepSignature(state) - cloudAgentExpireTokenAnchor("run-1", state, 256000) - if state.TokenAnchor.Accepted || !strings.Contains(state.TokenAnchor.RejectReason, "窗口已变化") { - t.Fatalf("换窗口后锚点必须作废:%+v", state.TokenAnchor) - } - transitions := 0 - for _, event := range state.Events { - if event.Type == "context_transition" && event.Payload["kind"] == "window_changed" { - transitions++ - before, _ := event.Payload["before"].(map[string]any) - after, _ := event.Payload["after"].(map[string]any) - if before["contextWindowTokens"] != 128000 || after["contextWindowTokens"] != 256000 { - t.Fatalf("窗口过渡事件缺少前后值:%+v", event.Payload) - } - } - } - if transitions != 1 { - t.Fatalf("窗口变化应落一条 context_transition(window_changed),实际 %d", transitions) - } - // 窗口未知(未解析到真实能力)时不比较,也不该据此作废。 - unknown := &cloudAgentRuntime{ - Request: agentTestRequest(), Step: 2, - TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-2", Step: 1, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000, ContextWindowTokens: 128000}, - } - unknown.TokenAnchor.Signature = cloudAgentStepSignature(unknown) - cloudAgentExpireTokenAnchor("run-1", unknown, 0) - if !unknown.TokenAnchor.Accepted { - t.Fatal("窗口未知时不该作废锚点") - } -} - -func TestCloudAgentTokenAnchorExpiresOnlyWhenRequestSignatureChanges(t *testing.T) { - for _, testCase := range []struct { - name, model, channelID, system, kind string - changeTools bool - }{ - {name: "model", model: "other-model", channelID: "channel", system: "policy", kind: "model_changed"}, - {name: "route", model: "text-test", channelID: "other-channel", system: "policy", kind: "route_changed"}, - {name: "system", model: "text-test", channelID: "channel", system: "new policy"}, - {name: "tools", model: "text-test", channelID: "channel", system: "policy", changeTools: true}, - } { - t.Run(testCase.name, func(t *testing.T) { - canonical := canonicalAgentRequest{SystemPrompt: "policy", Tools: []map[string]interface{}{{"name": "a"}}} - state := &cloudAgentRuntime{Request: agentTestRequest(), Step: 2} - signature := cloudAgentRequestSignature(state, canonical, "channel", "text-test") - state.TokenAnchor = &cloudAgentTokenAnchor{ - TaskID: "task", Step: 1, Accepted: true, InputTokens: 10000, EstimatedTokens: 18000, - Signature: signature, Model: "text-test", ChannelID: "channel", - } - canonical.Messages = []map[string]interface{}{{"role": "user", "content": "changed message"}} - cloudAgentExpireTokenAnchorForRequest("run", state, 0, cloudAgentRequestSignature(state, canonical, "channel", "text-test"), "text-test", "channel") - if !state.TokenAnchor.Accepted { - t.Fatal("messages alone must not invalidate the tokenizer anchor") - } - canonical.SystemPrompt = testCase.system - if testCase.changeTools { - canonical.Tools = []map[string]interface{}{{"name": "b"}} - } - cloudAgentExpireTokenAnchorForRequest("run", state, 0, cloudAgentRequestSignature(state, canonical, testCase.channelID, testCase.model), testCase.model, testCase.channelID) - if state.TokenAnchor.Accepted || state.TokenAnchor.RejectReason == "" { - t.Fatalf("signature changed without invalidation: %+v", state.TokenAnchor) - } - if testCase.kind != "" && (len(state.Events) != 1 || state.Events[0].Payload["kind"] != testCase.kind) { - t.Fatalf("missing transition %s: %+v", testCase.kind, state.Events) - } - }) - } -} - -// 上游实测与本地估算差出一个量级(<0.5× / >2×)时不采信,并说明原因。 -func TestCloudAgentTokenAnchorRejectsImplausibleProviderUsage(t *testing.T) { - cases := []struct { - name string - usage int64 - found string - }{ - {name: "远低于本地估算", usage: 5000, found: "远低于"}, - {name: "远高于本地估算", usage: 45000, found: "远高于"}, - } - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - service, db, _, _ := creationTestService(t) - request := agentTestRequest() - taskID := "task-" + testCase.name - if err := db.Create(&model.ApiCallLog{ - ID: "log-" + testCase.name, UserID: "user", TaskID: taskID, Capability: "text", Model: "text-test", - Status: model.ApiCallStatusSucceeded, InputTokens: testCase.usage, OutputTokens: 300, UsageAvailable: true, - }).Error; err != nil { - t.Fatal(err) - } - state := &cloudAgentRuntime{ - Request: request, Step: 2, LastStepTaskID: taskID, LastStepOperation: cloudAgentStepOperation, - LastStepEstimate: 20000, LastStepSourceBytes: 40000, - } - state.LastStepSignature = cloudAgentStepSignature(state) - service.recordCloudAgentTokenAnchor("user", state) - if state.TokenAnchor == nil || state.TokenAnchor.Accepted { - t.Fatalf("离谱的实测不该被采信:%+v", state.TokenAnchor) - } - if !strings.Contains(state.TokenAnchor.RejectReason, testCase.found) { - t.Fatalf("拒绝原因不对:%q", state.TokenAnchor.RejectReason) - } - payload := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) - if payload["tokenSource"] != "estimate" { - t.Fatalf("被拒绝的锚点不得当主读数:%+v", payload) - } - if reason, _ := payload["anchorRejected"].(string); !strings.Contains(reason, testCase.found) { - t.Fatalf("事件必须说明为什么不用这个实测:%+v", payload) - } - }) - } -} - -// 没有上游用量(usage_available=false)时不瞎猜:退回估算,且不产生锚点。 -func TestCloudAgentTokenAnchorRequiresProviderReportedUsage(t *testing.T) { - service, db, _, _ := creationTestService(t) - request := agentTestRequest() - if err := db.Create(&model.ApiCallLog{ - ID: "log-no-usage", UserID: "user", TaskID: "task-no-usage", Capability: "text", Model: "text-test", - Status: model.ApiCallStatusSucceeded, InputTokens: 10500, OutputTokens: 300, UsageAvailable: false, - }).Error; err != nil { - t.Fatal(err) - } - state := &cloudAgentRuntime{ - Request: request, Step: 2, LastStepTaskID: "task-no-usage", LastStepOperation: cloudAgentStepOperation, - LastStepEstimate: 20000, LastStepSourceBytes: 40000, - } - service.recordCloudAgentTokenAnchor("user", state) - if state.TokenAnchor != nil { - t.Fatalf("上游没报用量就不该定锚:%+v", state.TokenAnchor) - } - payload := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) - if payload["tokenSource"] != "estimate" || payload["normalizedInputTokens"] != 20000 { - t.Fatalf("没有实测时必须退回估算:%+v", payload) - } -} - -// 媒体任务不是"模型调用"这一步:拿它的用量当锚点会把压力算到错误的信封上。 -func TestCloudAgentTokenAnchorIgnoresNonStepOperations(t *testing.T) { - service, db, _, _ := creationTestService(t) - request := agentTestRequest() - if err := db.Create(&model.ApiCallLog{ - ID: "log-media", UserID: "user", TaskID: "task-media", Capability: "text", Model: "text-test", - Status: model.ApiCallStatusSucceeded, InputTokens: 10500, OutputTokens: 300, UsageAvailable: true, - }).Error; err != nil { - t.Fatal(err) - } - state := &cloudAgentRuntime{ - Request: request, Step: 2, LastStepTaskID: "task-media", LastStepOperation: "cloud_agent_media", - LastStepEstimate: 20000, LastStepSourceBytes: 40000, - } - service.recordCloudAgentTokenAnchor("user", state) - if state.TokenAnchor != nil { - t.Fatalf("非模型调用步骤不该定锚:%+v", state.TokenAnchor) - } -} - -func TestCloudAgentTokenAnchorOnlyUsesOwnedSuccessfulTextCall(t *testing.T) { - service, db, _, _ := creationTestService(t) - state := &cloudAgentRuntime{ - Request: agentTestRequest(), Step: 2, LastStepTaskID: "task-owned", - LastStepOperation: cloudAgentStepOperation, LastStepEstimate: 20000, - LastStepChannelID: "channel", - } - state.LastStepSignature = cloudAgentStepSignature(state) - for _, log := range []model.ApiCallLog{ - {ID: "other-user", UserID: "other", TaskID: "task-owned", Capability: "text", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000}, - {ID: "failed", UserID: "user", TaskID: "task-owned", Capability: "text", Status: model.ApiCallStatusFailed, UsageAvailable: true, InputTokens: 11000}, - {ID: "video", UserID: "user", TaskID: "task-owned", Capability: "video", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000}, - } { - if err := db.Create(&log).Error; err != nil { - t.Fatal(err) - } - } - service.recordCloudAgentTokenAnchor("user", state) - if state.TokenAnchor != nil { - t.Fatalf("foreign/failed/non-text log must not become an anchor: %+v", state.TokenAnchor) - } - valid := model.ApiCallLog{ID: "owned", UserID: "user", TaskID: "task-owned", ChannelID: "channel", Capability: "text", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000} - if err := db.Create(&valid).Error; err != nil { - t.Fatal(err) - } - service.recordCloudAgentTokenAnchor("user", state) - if state.TokenAnchor == nil || !state.TokenAnchor.Accepted { - t.Fatalf("owned successful text usage must be accepted: %+v", state.TokenAnchor) - } - state.TokenAnchor = nil - state.LastStepChannelID = "different-channel" - service.recordCloudAgentTokenAnchor("user", state) - if state.TokenAnchor != nil { - t.Fatalf("route changed after request construction: %+v", state.TokenAnchor) - } -} - -// 占用分布:分段合计必须与 system 桶严格对得上(字节逐字节、token 逐 token)。 -func TestCloudAgentContextBreakdownSystemSegmentsFillSystemBucket(t *testing.T) { - system, policy, err := compileCloudAgentPolicies(agentTestRequest(), nil, "", cloudAgentProfileSnapshot{Revision: agentProfileRevision(nil), Hash: agentProfileHash("")}) - if err != nil { - t.Fatal(err) - } - if len(policy.SystemSegments) == 0 { - t.Fatal("编译器必须登记系统提示分段") - } - // 编译之后拼接的个人记忆块:不登记就会让分段合计小于 system 桶。 - canonical := canonicalAgentRequest{ - SystemPrompt: system + "\n\n## 个人记忆\n\n- 标题:适用场景\n", - Messages: []map[string]interface{}{{"role": "user", "content": "分析剧情"}}, - Tools: []map[string]interface{}{{"type": "function", "function": map[string]interface{}{"name": "canvas_get_state"}}}, - } - cloudAgentRecordMemorySegment(&policy, canonical.SystemPrompt) - state := &cloudAgentRuntime{Canonical: canonical, Policy: policy} - - breakdown := cloudAgentContextBreakdownPayload(state) - segments, ok := breakdown["systemSegments"].([]cloudAgentContextSegment) - if !ok || len(segments) == 0 { - t.Fatalf("缺少系统提示分段:%+v", breakdown["systemSegments"]) - } - buckets, ok := breakdown["buckets"].([]map[string]any) - if !ok || len(buckets) != 3 { - t.Fatalf("buckets 不对:%+v", breakdown["buckets"]) - } - systemBucket := buckets[0] - if systemBucket["key"] != "system" { - t.Fatalf("第一个桶必须是 system:%+v", systemBucket) - } - segmentBytes, segmentTokens := 0, 0 - keys := []string{} - for _, segment := range segments { - segmentBytes += segment.Bytes - segmentTokens += segment.Tokens - keys = append(keys, segment.Key) - if segment.Bytes <= 0 { - t.Fatalf("分段 %s 的字节数必须为正:%+v", segment.Key, segment) - } - } - if segmentBytes != systemBucket["bytes"] || segmentBytes != len(canonical.SystemPrompt) { - t.Fatalf("分段字节合计 %d 与 system 桶 %v(真实 %d)对不上:%+v", segmentBytes, systemBucket["bytes"], len(canonical.SystemPrompt), keys) - } - if segmentTokens != systemBucket["tokens"] { - t.Fatalf("分段 token 合计 %d 与 system 桶 %v 对不上:%+v", segmentTokens, systemBucket["tokens"], keys) - } - if !strings.Contains(strings.Join(keys, ","), "memory") { - t.Fatalf("个人记忆必须登记为分段:%+v", keys) - } - if buckets[1]["key"] != "tools" || buckets[2]["key"] != "messages" { - t.Fatalf("三桶顺序不对:%+v", buckets) - } - // 未登记的内容归入"其它",合计仍然对得上,不会出现"少算一截"。 - state.Canonical.SystemPrompt += "\n未登记的一段拼接内容" - breakdown = cloudAgentContextBreakdownPayload(state) - segments = breakdown["systemSegments"].([]cloudAgentContextSegment) - buckets = breakdown["buckets"].([]map[string]any) - segmentBytes, segmentTokens, hasOther := 0, 0, false - for _, segment := range segments { - segmentBytes += segment.Bytes - segmentTokens += segment.Tokens - if segment.Key == "other" { - hasOther = true - } - } - if !hasOther { - t.Fatalf("零头必须归入 other:%+v", segments) - } - if segmentBytes != buckets[0]["bytes"] || segmentTokens != buckets[0]["tokens"] { - t.Fatalf("有零头时分段合计仍须等于 system 桶:%d/%v %d/%v", segmentBytes, buckets[0]["bytes"], segmentTokens, buckets[0]["tokens"]) - } -} - -// 有锚点时,占用分布另给一份按换算比例校准的读数,原值不覆盖。 -func TestCloudAgentContextBreakdownScalesBucketsWithAnchor(t *testing.T) { - state := &cloudAgentRuntime{ - Canonical: canonicalAgentRequest{SystemPrompt: "系统提示内容", Messages: []map[string]interface{}{{"role": "user", "content": "你好"}}}, - TokenAnchor: &cloudAgentTokenAnchor{ - TaskID: "task-1", Step: 1, Accepted: true, InputTokens: 10000, EstimatedTokens: 20000, - }, - } - breakdown := cloudAgentContextBreakdownPayload(state) - if breakdown["tokenScale"] != 0.5 { - t.Fatalf("tokenScale = %v,期望 0.5", breakdown["tokenScale"]) - } - buckets := breakdown["buckets"].([]map[string]any) - systemTokens := buckets[0]["tokens"].(int) - if buckets[0]["scaledTokens"].(int) != systemTokens/2 { - t.Fatalf("scaledTokens 应按锚点比例换算:%+v", buckets[0]) - } -} - -// 走真实运行路径(调度器推进 + 任务接缝注入模型输出):每一步都落一条 context_pressure, -// 第一步是估算、第二步换成上游实测锚点。这条用例守的是运行期接线(登记的是哪一步、 -// 哪一次调用、什么时候配锚点),而不是载荷函数本身。 -func TestCloudAgentRuntimeEmitsContextPressurePerStep(t *testing.T) { - s, db, _, _ := creationTestService(t) - if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}).Error; err != nil { - t.Fatal(err) - } - root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "") - if err != nil { - t.Fatal(err) - } - load := func() (*model.CloudAgentExecution, cloudAgentRuntime) { - t.Helper() - run, err := s.repo.CloudAgent("user", root.ID) - if err != nil { - t.Fatal(err) - } - state, err := cloudAgentDecode(run) - if err != nil { - t.Fatal(err) - } - return run, state - } - advance := func() { - t.Helper() - if err := s.advanceCloudAgentByID("user", root.ID); err != nil { - t.Fatal(err) - } - } - pressures := func(state cloudAgentRuntime) []CloudAgentEvent { - events := []CloudAgentEvent{} - for _, event := range state.Events { - if event.Type == "context_pressure" { - events = append(events, event) - } - } - return events - } - - // 建运行:第一步的模型调用就是根任务,读数在发出请求之前就落下了。 - advance() - _, state := load() - if state.ActiveTaskID != root.ID || state.LastStepTaskID != root.ID || state.LastStepOperation != cloudAgentStepOperation { - t.Fatalf("第一步的记账不对:active=%s last=%s op=%s", state.ActiveTaskID, state.LastStepTaskID, state.LastStepOperation) - } - first := pressures(state) - if len(first) != 1 { - t.Fatalf("第一步应落一条 context_pressure,实际 %d", len(first)) - } - if first[0].Payload["tokenSource"] != "estimate" || first[0].Payload["measurementSource"] != "estimate" { - t.Fatalf("第一步没有实测,必须标估算:%+v", first[0].Payload) - } - if first[0].Payload["requestId"] != root.ID || first[0].Payload["readingScope"] != "next_request" { - t.Fatalf("读数必须钉在本次请求上:%+v", first[0].Payload) - } - if _, ok := first[0].Payload["breakdown"].(map[string]any); !ok { - t.Fatalf("读数必须带占用分布:%+v", first[0].Payload) - } - assertMatchesTask := func(taskID string, event CloudAgentEvent) { - t.Helper() - task, err := s.repo.TaskForUser("user", taskID) - if err != nil { - t.Fatal(err) - } - var input map[string]any - if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { - t.Fatal(err) - } - canonical, ok := canonicalAgentRequestFromInput(input) - if !ok { - t.Fatalf("task %s missing canonical envelope", taskID) - } - raw, err := json.Marshal(canonical) - if err != nil { - t.Fatal(err) - } - breakdown := event.Payload["breakdown"].(map[string]any) - if breakdown["totalBytes"] != float64(len(raw)) || event.Payload["sourceBytes"] != float64(len(raw)) { - t.Fatalf("pressure/breakdown not based on actual task envelope: task=%s event=%+v", taskID, event.Payload) - } - buckets := breakdown["buckets"].([]any) - messages := buckets[2].(map[string]any) - messageBytes, _ := json.Marshal(canonical.Messages) - if messages["bytes"] != float64(len(messageBytes)) { - t.Fatalf("message bucket not based on task input: %v != %d", messages["bytes"], len(messageBytes)) - } - } - assertMatchesTask(root.ID, first[0]) - - // 第一步的模型调用回来了,上游上报了用量。 - if err := db.Create(&model.ApiCallLog{ - ID: "log-root", UserID: "user", TaskID: root.ID, Capability: "text", Model: "text-test", - Status: model.ApiCallStatusSucceeded, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, UsageAvailable: true, - }).Error; err != nil { - t.Fatal(err) - } - // 在任务接缝注入模型输出:一次只读工具调用,让这一轮进入第二步。 - call := cloudAgentCall{ID: "call-1"} - call.Function.Name, call.Function.Arguments = "canvas_get_state", `{}` - body, _ := json.Marshal(map[string]any{"toolCalls": []cloudAgentCall{call}}) - if err := db.Model(&model.Task{}).Where("id = ?", state.ActiveTaskID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": string(body)}).Error; err != nil { - t.Fatal(err) - } - // 模型结果 → 执行工具 → 记锚点 → 发下一步:推进到第二步的读数落下为止(有界)。 - for attempt := 0; attempt < 6; attempt++ { - advance() - if _, current := load(); len(pressures(current)) == 2 { - break - } - } - run, state := load() - if run.Status != "running" { - t.Fatalf("这一步应当继续(工具已执行、下一步已提交):status=%s failure=%s", run.Status, run.FailureMessage) - } - if state.TokenAnchor == nil || !state.TokenAnchor.Accepted || state.TokenAnchor.TaskID != root.ID { - t.Fatalf("根任务的上游用量必须配成锚点:%+v", state.TokenAnchor) - } - if state.LastStepTaskID == root.ID || state.LastStepOperation != cloudAgentStepOperation { - t.Fatalf("第二步的记账不对:last=%s op=%s", state.LastStepTaskID, state.LastStepOperation) - } - second := pressures(state) - if len(second) != 2 { - t.Fatalf("第二步应再落一条 context_pressure,实际 %d", len(second)) - } - payload := second[1].Payload - // 事件是经 JSON 落库再读回来的,数字到这一层都是 float64(消费方也不该假设整型)。 - if payload["tokenSource"] != "provider" || payload["measurementSource"] != "provider" { - t.Fatalf("拿到实测后主读数必须换成 provider:%+v", payload) - } - if payload["normalizedInputTokens"] != float64(10500) || payload["requestId"] != state.LastStepTaskID { - t.Fatalf("实测值与请求绑定不对:%+v", payload) - } - assertMatchesTask(state.LastStepTaskID, second[1]) - if _, exists := payload["tokenScale"]; !exists { - t.Fatalf("provider 口径必须带换算比例:%+v", payload) - } - anchor, ok := payload["anchor"].(map[string]any) - if !ok || anchor["valid"] != true || anchor["ageSteps"] != float64(0) || anchor["id"] != root.ID { - t.Fatalf("anchor 快照不对:%+v", payload["anchor"]) - } -} diff --git a/backend/internal/app/cloud_agent_context_test.go b/backend/internal/app/cloud_agent_context_test.go index 4f91edd79..86a921771 100644 --- a/backend/internal/app/cloud_agent_context_test.go +++ b/backend/internal/app/cloud_agent_context_test.go @@ -205,3 +205,48 @@ func TestCloudAgentSkillsLoadOnDemandAndPage(t *testing.T) { t.Fatal("mixed skill version") } } + +func TestCloudAgentCompactsSupersededCanvasReadWithoutBreakingToolPairs(t *testing.T) { + old := cloudAgentCall{ID: "old-read"} + old.Function.Name, old.Function.Arguments = "canvas_get_state", `{"offset":0}` + current := cloudAgentCall{ID: "current-read"} + current.Function.Name, current.Function.Arguments = "canvas_get_state", `{"offset":0}` + oldResult, _ := json.Marshal(map[string]any{ + "snapshotHash": "old-snapshot", + "nodes": []any{map[string]any{"id": "n1", "content": strings.Repeat("正文", 200), "generation": map[string]any{"taskId": "task-1"}}}, + "connections": []any{map[string]any{"fromNodeId": "n1", "toNodeId": "n2"}}, + }) + currentResult, _ := json.Marshal(map[string]any{ + "snapshotHash": "new-snapshot", + "nodes": []any{map[string]any{"id": "n1", "content": "最新正文"}}, + }) + state := &cloudAgentRuntime{ + Calls: []cloudAgentCall{current}, + Canonical: canonicalAgentRequest{Messages: []map[string]any{ + {"role": "assistant", "content": "", "tool_calls": []cloudAgentCall{old}}, + {"role": "tool", "tool_call_id": old.ID, "content": string(oldResult)}, + {"role": "assistant", "content": "", "tool_calls": []cloudAgentCall{current}}, + {"role": "tool", "tool_call_id": current.ID, "content": string(currentResult)}, + }}, + } + + cloudAgentCompactSupersededReadResult(state, current) + messages := state.Canonical.Messages + if len(messages) != 4 || stringValue(messages[1]["tool_call_id"]) != old.ID || stringValue(messages[3]["tool_call_id"]) != current.ID { + t.Fatalf("tool pairing changed: %+v", messages) + } + var compacted map[string]any + if err := json.Unmarshal([]byte(stringValue(messages[1]["content"])), &compacted); err != nil { + t.Fatal(err) + } + if compacted["snapshotHash"] != "old-snapshot" || compacted["nodes"] != nil || compacted["connections"] != nil || compacted["contextCompacted"] != true { + t.Fatalf("old canvas body was not compacted safely: %#v", compacted) + } + facts, ok := compacted["observedFacts"].([]any) + if !ok || len(facts) != 1 || facts[0].(map[string]any)["generation"].(map[string]any)["taskId"] != "task-1" { + t.Fatalf("execution facts were lost: %#v", compacted["observedFacts"]) + } + if stringValue(messages[3]["content"]) != string(currentResult) { + t.Fatal("latest canvas read was compacted") + } +} diff --git a/backend/internal/app/cloud_agent_continuation_test.go b/backend/internal/app/cloud_agent_continuation_test.go index e9758a4c3..145a87400 100644 --- a/backend/internal/app/cloud_agent_continuation_test.go +++ b/backend/internal/app/cloud_agent_continuation_test.go @@ -192,9 +192,13 @@ func TestCloudAgentContinuationSourceNeverReachesUpstreamBody(t *testing.T) { if got := stringField(canonical.Messages[0], cloudAgentContextSourceKey); got != "continuation" { t.Fatalf("canonical 丢了来源标记:%q", got) } + responses, err := canonicalAgentResponsesBody(&canonical) + if err != nil { + t.Fatal(err) + } for _, body := range []map[string]any{ canonicalAgentChatBody(&canonical, false), - canonicalAgentResponsesBody(&canonical), + responses, canonicalAgentGeminiBody(&canonical), claudeAgentBody(canonicalAgentChatBody(&canonical, true)), } { diff --git a/backend/internal/app/cloud_agent_lessons.go b/backend/internal/app/cloud_agent_lessons.go index a16edf5df..5216cb1c3 100644 --- a/backend/internal/app/cloud_agent_lessons.go +++ b/backend/internal/app/cloud_agent_lessons.go @@ -108,26 +108,6 @@ func (s *Service) attachCloudAgentLessons(canonical *canonicalAgentRequest, user } } -// cloudAgentRecordMemorySegment 把系统提示里已经存在的个人记忆块登记为分段。 -// 记忆块是在策略编译之后拼接的,编译器录不到它;不登记就会让"系统提示分段"合计 -// 小于 system 桶,读数看起来像少算了一截。 -func cloudAgentRecordMemorySegment(policy *cloudAgentPolicySnapshot, system string) { - if policy == nil { - return - } - index := strings.Index(system, cloudAgentLessonBlockMarker) - if index < 0 { - for i := range policy.SystemSegments { - if policy.SystemSegments[i].Key == "memory" { - policy.SystemSegments = append(policy.SystemSegments[:i], policy.SystemSegments[i+1:]...) - break - } - } - return - } - cloudAgentRecordSystemSegment(policy, "memory", "个人记忆", system[index:]) -} - func cloudAgentRememberLesson(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall) (any, error) { if state == nil { return nil, BadAuthRequest("当前运行状态无效") diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index 6c2f9cda8..5c035a1d7 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -1,6 +1,7 @@ package app import ( + "context" "encoding/json" "errors" "fmt" @@ -244,6 +245,25 @@ func cloudAgentMediaTargetIssue(node map[string]any, nodeType string) (string, s return "", "" } +func cloudAgentMediaAdmissionRetryable(err error) bool { + if err == nil { + return false + } + var appErr *AppError + if errors.As(err, &appErr) && appErr.Retryable { + return true + } + var httpErr providerHTTPError + if errors.As(err, &httpErr) { + return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429 || httpErr.StatusCode == 408 + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var circuitErr providerCircuitOpenError + return errors.As(err, &circuitErr) +} + type cloudAgentMediaAdmissionError struct { error Reason string @@ -256,9 +276,9 @@ type cloudAgentMediaAdmissionError struct { func (e *cloudAgentMediaAdmissionError) Unwrap() error { return e.error } // cloudAgentWrapMediaAdmissionError turns an untyped failure from the media -// admission boundary into an explicit, non-repairable failure. A billed write -// must not ask the model to guess alternative sizes, routes, or provider -// parameters after the server has rejected the admitted contract. +// admission boundary into an explicit failure classification. Only errors that +// are independently known to be transient may continue automatically; model, +// parameter, permission and budget errors remain terminal before submission. func cloudAgentWrapMediaAdmissionError(err error) error { if err == nil { return nil @@ -271,12 +291,17 @@ func cloudAgentWrapMediaAdmissionError(err error) error { if errors.As(err, &admissionErr) { return err } + retryable := cloudAgentMediaAdmissionRetryable(err) + action := "report_to_user" + if retryable { + action = "retry" + } return &cloudAgentMediaAdmissionError{ error: err, Reason: "media_admission_failed", ErrorClass: cloudAgentToolErrorAdmission, - Retryable: false, - RequiredAction: "report_to_user", + Retryable: retryable, + RequiredAction: action, } } @@ -312,10 +337,10 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin existingMeta, _ := existing["metadata"].(map[string]any) targetDescriptor, supported := cloudAgentNodeCapabilityForGenerationMode(args.Mode) if !supported { - return nil, nil, nil, BadAuthRequest("生成模式当前不受 Agent 支持") + return nil, nil, nil, cloudAgentFieldError("mode", "invalid_value", "生成模式当前不受 Agent 支持;请使用工具 schema 中列出的模式") } if err := validateCloudAgentID(args.NodeID, "生成节点 ID", 80); err != nil { - return nil, nil, nil, err + return nil, nil, nil, cloudAgentFieldError("nodeId", "invalid_value", cloudAgentSafeToolError(err)) } if existing != nil { if reason, issue := cloudAgentMediaTargetIssue(existing, targetDescriptor.Type); reason != "" { @@ -340,13 +365,13 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin } } if args.SourceNodeID != "" && nodes[args.SourceNodeID] == nil { - return nil, nil, nil, BadAuthRequest("来源镜头节点不在当前画布") + return nil, nil, nil, cloudAgentFieldError("sourceNodeId", "not_found", "来源节点不在当前画布;请重新读取画布并使用真实文本节点 ID,或留空") } if source := nodes[args.SourceNodeID]; source != nil { descriptor, known := cloudAgentNodeCapabilityForNode(source) // 角色卡作为文本来源时只取其角色设定(由 cloudAgentMediaCharacters 校验非空)。 if !known || !descriptor.Connection.CanSource || (descriptor.InputKind != "text" && descriptor.InputKind != "character") { - return nil, nil, nil, BadAuthRequest("sourceNodeId 仅接受可作为文本输入的节点;媒体资产请放入 referenceNodeIds,并将 sourceNodeId 留空,不要重复传入") + return nil, nil, nil, cloudAgentFieldError("sourceNodeId", "invalid_value", "sourceNodeId 仅接受可作为文本输入的节点;媒体资产请放入 referenceNodeIds,并将 sourceNodeId 留空") } } // Keep the media entry point subject to the same graph admission policy as @@ -365,9 +390,20 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin if existing == nil { prospectiveNodes = append(prospectiveNodes, target) } - prospectiveSources := append(append([]string{}, args.ReferenceNodeIDs...), args.SourceNodeID) + type prospectiveSource struct { + id string + field string + } + prospectiveSources := make([]prospectiveSource, 0, len(args.ReferenceNodeIDs)+1) + for i, id := range args.ReferenceNodeIDs { + prospectiveSources = append(prospectiveSources, prospectiveSource{id: id, field: fmt.Sprintf("referenceNodeIds[%d]", i)}) + } + if args.SourceNodeID != "" { + prospectiveSources = append(prospectiveSources, prospectiveSource{id: args.SourceNodeID, field: "sourceNodeId"}) + } prospectiveSeen := map[string]bool{} - for _, sourceID := range prospectiveSources { + for _, source := range prospectiveSources { + sourceID := source.id if sourceID == "" || prospectiveSeen[sourceID] { continue } @@ -383,7 +419,7 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin continue } if err := validateCloudAgentConnection(prospectiveNodes, sourceID, args.NodeID, prospectiveConnections); err != nil { - return nil, nil, nil, err + return nil, nil, nil, cloudAgentFieldError(source.field, "invalid_connection", cloudAgentSafeToolError(err)) } prospectiveConnections = append(prospectiveConnections, map[string]any{"fromNodeId": sourceID, "toNodeId": args.NodeID}) } @@ -393,11 +429,16 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin } refs := map[string]any{} seen := map[string]bool{} - for _, id := range args.ReferenceNodeIDs { + for i, id := range args.ReferenceNodeIDs { if id == "" || seen[id] || nodes[id] == nil { - return nil, nil, nil, BadAuthRequest("参考节点不存在或重复") + return nil, nil, nil, cloudAgentFieldError(fmt.Sprintf("referenceNodeIds[%d]", i), "invalid_value", "参考节点不存在或重复;请重新读取画布并只使用真实、唯一的媒体节点 ID") } seen[id] = true + if !cloudAgentCharacterNode(nodes[id]) { + if _, _, err := cloudAgentReferenceDescriptor(nodes[id]); err != nil { + return nil, nil, nil, cloudAgentFieldError(fmt.Sprintf("referenceNodeIds[%d]", i), "invalid_value", "referenceNodeIds 仅接受受支持的媒体参考节点;文本来源请改用 sourceNodeId") + } + } ref, payloadField, e := cloudAgentMediaReference(repo, userID, canvas.ProjectID, nodes[id]) if e != nil { return nil, nil, nil, e @@ -410,10 +451,16 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin if len(transient) > 0 && transient[0] != nil { available = transient[0] } - for _, id := range args.ReferenceTransientIDs { + for i, id := range args.ReferenceTransientIDs { ref, ok := available[id] - if !ok || !strings.HasPrefix(ref.MIMEType, "image/") || ref.ResourceID == "" || time.Now().After(ref.ExpiresAt) { - return nil, nil, nil, BadAuthRequest("临时参考图不存在、类型不匹配或已过期,请重新渲染标注") + if !ok { + return nil, nil, nil, BadAuthRequest("临时参考图不存在或已不可用,请重新渲染标注") + } + if !strings.HasPrefix(ref.MIMEType, "image/") { + return nil, nil, nil, cloudAgentFieldError(fmt.Sprintf("referenceTransientIDs[%d]", i), "invalid_value", "临时参考图必须是图片;请使用图片参考或移除该 ID") + } + if ref.ResourceID == "" || time.Now().After(ref.ExpiresAt) { + return nil, nil, nil, BadAuthRequest("临时参考图资源未就绪或已过期,请重新渲染标注") } resolved, _, err := cloudAgentReference(repo, userID, map[string]any{"id": ref.ID, "type": "image", "title": ref.Name, "metadata": map[string]any{"storageKey": "resource:" + ref.ResourceID}}) if err != nil { @@ -429,52 +476,52 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime) error { mode := strings.ToLower(strings.TrimSpace(a.Mode)) if !cloudAgentGenerationModeSupported(mode) { - return BadAuthRequest("生成模式当前不受 Agent 支持") + return cloudAgentFieldError("mode", "invalid_value", "生成模式当前不受 Agent 支持;请使用工具 schema 中列出的模式") } a.Mode = mode if a.SnapshotHash == "" { - return BadAuthRequest("缺少画布快照,请先读取当前画布") + return cloudAgentFieldError("snapshotHash", "required", "缺少画布快照,请先读取当前画布后重试") } if len(a.SnapshotHash) != 64 { - return BadAuthRequest("画布快照无效,请重新读取当前画布") + return cloudAgentFieldError("snapshotHash", "invalid_value", "画布快照无效,请重新读取当前画布") } for _, r := range a.SnapshotHash { if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { - return BadAuthRequest("画布快照无效,请重新读取当前画布") + return cloudAgentFieldError("snapshotHash", "invalid_value", "画布快照无效,请重新读取当前画布") } } if err := validateCloudAgentID(a.NodeID, "生成节点 ID", 80); err != nil { - return err + return cloudAgentFieldError("nodeId", "invalid_value", cloudAgentSafeToolError(err)) } if a.SourceNodeID != "" { if err := validateCloudAgentID(a.SourceNodeID, "来源节点 ID", 80); err != nil { - return err + return cloudAgentFieldError("sourceNodeId", "invalid_value", cloudAgentSafeToolError(err)) } } - for _, id := range a.ReferenceNodeIDs { + for i, id := range a.ReferenceNodeIDs { if err := validateCloudAgentID(id, "参考节点 ID", 80); err != nil { - return err + return cloudAgentFieldError(fmt.Sprintf("referenceNodeIds[%d]", i), "invalid_value", cloudAgentSafeToolError(err)) } } if len(a.ReferenceTransientIDs) > 4 { - return BadAuthRequest("临时参考图最多 4 个") + return cloudAgentFieldError("referenceTransientIds", "item_count", "临时参考图最多 4 个,请减少引用数量") } - for _, id := range a.ReferenceTransientIDs { + for i, id := range a.ReferenceTransientIDs { if err := validateCloudAgentID(id, "临时参考图 ID", 120); err != nil { - return err + return cloudAgentFieldError(fmt.Sprintf("referenceTransientIds[%d]", i), "invalid_value", cloudAgentSafeToolError(err)) } } if strings.TrimSpace(a.Prompt) == "" { - return BadAuthRequest("生成提示词不能为空") + return cloudAgentFieldError("prompt", "required", "生成提示词不能为空,请补充完整提示词") } if strings.TrimSpace(a.Title) == "" || utf8.RuneCountInString(a.Title) > 240 { - return BadAuthRequest("生成节点标题不能为空且不能超过 240 个字符") + return cloudAgentFieldError("title", "invalid_value", "生成节点标题不能为空且不能超过 240 个字符") } if utf8.RuneCountInString(a.Prompt) > 16000 { - return BadAuthRequest(fmt.Sprintf("提示词共%d字符,超过16000字符上限;请先告知用户,不要擅自删改关键内容", utf8.RuneCountInString(a.Prompt))) + return cloudAgentFieldError("prompt", "length_exceeded", fmt.Sprintf("提示词共%d字符,超过16000字符上限;请先告知用户,不要擅自删改关键内容", utf8.RuneCountInString(a.Prompt))) } if a.Duration < 0 { - return BadAuthRequest("生成时长不能为负数") + return cloudAgentFieldError("durationSeconds", "invalid_value", "生成时长不能为负数,请传入非负整数") } switch mode { case "video": @@ -482,19 +529,19 @@ func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime // during task admission; an explicit unsupported value still fails there. case "image", "audio": if a.Duration != 0 { - return BadAuthRequest("只有视频生成允许设置 durationSeconds") + return cloudAgentFieldError("durationSeconds", "invalid_value", "只有视频生成允许设置 durationSeconds;图片或音频生成请省略该字段") } if a.VideoGenerateAudio != nil { - return BadAuthRequest("videoGenerateAudio 仅适用于视频生成") + return cloudAgentFieldError("videoGenerateAudio", "invalid_value", "videoGenerateAudio 仅适用于视频生成;图片或音频生成请省略该字段") } } if len(a.ReferenceNodeIDs) > 16 { - return BadAuthRequest("参考节点最多 16 个") + return cloudAgentFieldError("referenceNodeIds", "item_count", "参考节点最多 16 个,请减少引用数量") } if a.SourceNodeID != "" { - for _, id := range a.ReferenceNodeIDs { + for i, id := range a.ReferenceNodeIDs { if id == a.SourceNodeID { - return BadAuthRequest("sourceNodeId 与 referenceNodeIds 不能重复;参考图片、视频、音频请仅保留在 referenceNodeIds 并清空 sourceNodeId,文本镜头节点则仅放 sourceNodeId") + return cloudAgentFieldError(fmt.Sprintf("referenceNodeIds[%d]", i), "duplicate", "sourceNodeId 与 referenceNodeIds 不能重复;媒体资产仅放入 referenceNodeIds,文本来源仅放入 sourceNodeId") } } } @@ -517,26 +564,26 @@ func validateCloudAgentMediaReferences(mode string, refs map[string]any) error { switch mode { case "image": if videoCount > 0 || audioCount > 0 { - return BadAuthRequest("图片生成仅支持图片参考资产") + return cloudAgentFieldError("referenceNodeIds", "invalid_combination", "图片生成仅支持图片参考资产;请移除视频或音频引用") } case "audio": if videoCount > 0 { - return BadAuthRequest("音频生成不能使用参考视频") + return cloudAgentFieldError("referenceNodeIds", "invalid_combination", "音频生成不能使用参考视频;请移除视频引用") } if imageCount > 0 && audioCount > 0 { - return BadAuthRequest("参考图片和参考音频不能同时使用") + return cloudAgentFieldError("referenceNodeIds", "invalid_combination", "参考图片和参考音频不能同时使用;请只保留一种媒体类型") } if imageCount > 1 { - return BadAuthRequest("音频生成最多使用 1 张参考图片") + return cloudAgentFieldError("referenceNodeIds", "item_count", "音频生成最多使用 1 张参考图片,请减少图片引用") } if audioCount > 3 { - return BadAuthRequest("音频生成最多使用 3 段参考音频") + return cloudAgentFieldError("referenceNodeIds", "item_count", "音频生成最多使用 3 段参考音频,请减少音频引用") } case "video": // Video reference admission is completed against the selected model's // capability contract by CreateTask. Do not guess a provider operation here. default: - return BadAuthRequest("生成模式尚未实现媒体任务适配器") + return cloudAgentFieldError("mode", "invalid_value", "生成模式尚未实现媒体任务适配器;请使用工具 schema 中列出的模式") } return nil } diff --git a/backend/internal/app/cloud_agent_media_test.go b/backend/internal/app/cloud_agent_media_test.go index 317dd3de4..56b74fb8a 100644 --- a/backend/internal/app/cloud_agent_media_test.go +++ b/backend/internal/app/cloud_agent_media_test.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "errors" "strings" "testing" @@ -230,6 +231,68 @@ func TestCloudAgentMediaReferenceAndSnapshotGuards(t *testing.T) { } } +func TestCloudAgentMediaArgumentFailureContinuesRun(t *testing.T) { + s, db, args := agentMediaFixture(t) + args.SourceNodeID = "cat" // Existing media node, invalid as a text source. + args.ReferenceNodeIDs = []string{"hero"} + run, state := agentMediaRun(t, s, args, "request_approval") + if err := s.advanceCloudAgentTool(run, &state); err != nil { + t.Fatal(err) + } + stored, err := s.repo.CloudAgent("user", run.ID) + if err != nil { + t.Fatal(err) + } + state, err = cloudAgentDecode(stored) + if err != nil { + t.Fatal(err) + } + if stored.Status != "running" { + t.Fatalf("repairable media argument failure terminated run: status=%s message=%s", stored.Status, stored.FailureMessage) + } + var failed *CloudAgentEvent + for i := range state.Events { + if state.Events[i].Type == "tool_failed" { + failed = &state.Events[i] + } + if state.Events[i].Type == "run_failed" { + t.Fatalf("argument error emitted run_failed: %+v", state.Events[i]) + } + } + if failed == nil { + t.Fatal("missing tool_failed event") + } + result, _ := failed.Payload["result"].(map[string]any) + if result["field"] != "sourceNodeId" || result["issue"] != "invalid_value" || result["requiredAction"] != "fix_arguments" { + t.Fatalf("missing actionable field feedback: %+v", result) + } + var tasks int64 + if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil { + t.Fatal(err) + } + if tasks != 0 { + t.Fatalf("invalid arguments submitted %d media tasks", tasks) + } +} + +func TestCloudAgentUnavailableTransientReferenceIsNotRetryable(t *testing.T) { + s, _, args := agentMediaFixture(t) + args.ReferenceNodeIDs = nil + args.ReferenceTransientIDs = []string{"missing-transient"} + _, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", args, map[string]cloudAgentTransientReference{}) + if err == nil { + t.Fatal("missing transient reference was accepted") + } + var argumentErr *cloudAgentArgumentError + if errors.As(err, &argumentErr) { + t.Fatalf("unavailable transient reference was classified as a model-correctable argument: %v", err) + } + class, retryable, action := cloudAgentToolErrorClass(agentTestRequest(), agentMediaCall(args), cloudAgentWrapMediaAdmissionError(err), true) + if class != cloudAgentToolErrorAdmission || retryable || action != "report_to_user" { + t.Fatalf("unavailable transient reference entered correction retry: class=%s retryable=%v action=%s", class, retryable, action) + } +} + func TestCloudAgentImageCreatesReferencedNode(t *testing.T) { s, db, a := agentMediaFixture(t) capability := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceGrokImage), "grok-image") diff --git a/backend/internal/app/cloud_agent_pi_bridge.go b/backend/internal/app/cloud_agent_pi_bridge.go index 2930b5bd0..b1234efc9 100644 --- a/backend/internal/app/cloud_agent_pi_bridge.go +++ b/backend/internal/app/cloud_agent_pi_bridge.go @@ -21,7 +21,38 @@ import ( // canonicalFromRuntimeMessages 把运行时的会话消息(user/assistant/toolResult, // 内容块为 text/image/thinking/toolCall)转换成供应商层接受的规范格式。 -func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string]interface{}, systemPrompt string) canonicalAgentRequest { +const cloudAgentPiCompactionSummaryMessagePrefix = "The conversation history before this point was compacted into the following summary:\n\n\n" +const cloudAgentPiCompactionSummaryMessageSuffix = "\n" + +// strictRuntimeStringAliases resolves protocol aliases without silently +// choosing one when two producers disagree. +func strictRuntimeStringAliases(fields map[string]interface{}, label string, aliases ...string) (string, error) { + value := "" + firstAlias := "" + for _, alias := range aliases { + raw, present := fields[alias] + if !present || raw == nil { + continue + } + text, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s 字段 %q 必须是字符串", label, alias) + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + if value != "" && value != text { + return "", fmt.Errorf("%s 的别名 %q 与 %q 冲突", label, firstAlias, alias) + } + if value == "" { + value, firstAlias = text, alias + } + } + return value, nil +} + +func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string]interface{}, systemPrompt string) (canonicalAgentRequest, error) { canonical := canonicalAgentRequest{Tools: tools, ToolChoice: "auto", SystemPrompt: systemPrompt} for _, message := range messages { switch stringField(message, "role") { @@ -29,6 +60,14 @@ func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string] if text := runtimeContentText(message["content"]); strings.TrimSpace(text) != "" { canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "system", "content": text}) } + case "compactionSummary": + summary := stringField(message, "summary") + if summary != "" { + canonical.Messages = append(canonical.Messages, map[string]interface{}{ + "role": "user", + "content": cloudAgentPiCompactionSummaryMessagePrefix + summary + cloudAgentPiCompactionSummaryMessageSuffix, + }) + } case "user": canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "user", "content": runtimeUserContent(message["content"])}) case "assistant": @@ -45,10 +84,25 @@ func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string] if part["arguments"] == nil { arguments = []byte("{}") } - calls = append(calls, map[string]interface{}{ - "id": stringField(part, "id"), + callID, err := strictRuntimeStringAliases(part, "Pi 工具调用 ID", "id", "call_id", "toolCallId", "tool_call_id") + if err != nil { + return canonicalAgentRequest{}, err + } + if callID == "" { + return canonicalAgentRequest{}, errors.New("Pi 工具调用缺少 call id") + } + itemID, err := strictRuntimeStringAliases(part, "Pi 工具调用 item id", "item_id", "itemId") + if err != nil { + return canonicalAgentRequest{}, err + } + call := map[string]interface{}{ + "id": callID, "function": map[string]interface{}{"name": stringField(part, "name"), "arguments": string(arguments)}, - }) + } + if itemID != "" { + call["item_id"] = itemID + } + calls = append(calls, call) } } } else { @@ -63,9 +117,12 @@ func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string] } canonical.Messages = append(canonical.Messages, converted) case "toolResult", "tool": - id := firstNonEmpty(stringField(message, "toolCallId"), stringField(message, "tool_call_id")) + id, err := strictRuntimeStringAliases(message, "Pi 工具结果 call id", "toolCallId", "tool_call_id", "call_id") + if err != nil { + return canonicalAgentRequest{}, err + } if id == "" { - continue + return canonicalAgentRequest{}, errors.New("Pi 工具结果缺少 call id") } canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "tool", "tool_call_id": id, "content": runtimeContentText(message["content"])}) } @@ -82,7 +139,85 @@ func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string] canonical.Messages = append([]map[string]interface{}{{"role": "system", "content": systemPrompt}}, canonical.Messages...) } } - return canonical + return canonical, nil +} + +func validateRuntimeMessagesForModel(messages []map[string]any) error { + pending := make(map[string]struct{}) + for _, message := range messages { + switch stringField(message, "role") { + case "assistant": + if len(pending) > 0 { + return fmt.Errorf("Pi 工具调用 %q 缺少结果", firstPendingRuntimeCall(pending)) + } + parts, _ := message["content"].([]interface{}) + for _, value := range parts { + part, _ := value.(map[string]interface{}) + if stringField(part, "type") != "toolCall" { + continue + } + callID, err := strictRuntimeStringAliases(part, "Pi 工具调用 ID", "id", "call_id", "toolCallId", "tool_call_id") + if err != nil { + return err + } + if callID == "" { + return errors.New("Pi 工具调用缺少 call id") + } + if _, exists := pending[callID]; exists { + return fmt.Errorf("Pi 工具调用 %q 重复或缺少结果", callID) + } + arguments := part["arguments"] + if arguments == nil { + arguments = map[string]interface{}{} + } + encoded, err := json.Marshal(arguments) + if err != nil { + return fmt.Errorf("Pi 工具调用 %q 参数无效: %w", callID, err) + } + var object map[string]interface{} + if err := json.Unmarshal(encoded, &object); err != nil || object == nil { + return fmt.Errorf("Pi 工具调用 %q 参数不是 JSON 对象", callID) + } + pending[callID] = struct{}{} + } + case "toolResult", "tool": + callID, err := strictRuntimeStringAliases(message, "Pi 工具结果 call id", "toolCallId", "tool_call_id", "call_id") + if err != nil { + return err + } + if callID == "" { + return errors.New("Pi 工具结果缺少 call id") + } + if _, exists := pending[callID]; !exists { + return fmt.Errorf("Pi 工具结果 %q 没有对应的工具调用", callID) + } + delete(pending, callID) + case "user", "system": + if len(pending) > 0 { + return fmt.Errorf("Pi 工具调用 %q 缺少结果", firstPendingRuntimeCall(pending)) + } + } + } + if len(pending) > 0 { + return fmt.Errorf("Pi 工具调用 %q 缺少结果", firstPendingRuntimeCall(pending)) + } + return nil +} + +func firstPendingRuntimeCall(pending map[string]struct{}) string { + for callID := range pending { + return callID + } + return "" +} + +func cloudAgentPiHasCompactionSummary(messages []map[string]any) bool { + for _, message := range messages { + if stringField(message, "role") == "compactionSummary" && strings.TrimSpace(stringField(message, "summary")) != "" { + return true + } + } + return false } func runtimeContentText(content interface{}) string { @@ -158,7 +293,11 @@ func runtimeToolCalls(calls []cloudAgentCall) []map[string]any { if strings.TrimSpace(call.Function.Arguments) != "" { _ = json.Unmarshal([]byte(call.Function.Arguments), &arguments) } - result = append(result, map[string]any{"id": call.ID, "name": call.Function.Name, "arguments": arguments}) + entry := map[string]any{"id": call.ID, "name": call.Function.Name, "arguments": arguments} + if call.ItemID != "" { + entry["item_id"] = call.ItemID + } + result = append(result, entry) } return result } @@ -166,25 +305,32 @@ func runtimeToolCalls(calls []cloudAgentCall) []map[string]any { // cloudAgentPiTool 工具调用桥接:所有工具都交给唯一的 Go 业务执行器 // advanceCloudAgentTool(权限、审批、计费、画布写入都在那里治理)。 func (s *Service) cloudAgentPiTool(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { - var request struct { - CallID string `json:"callId"` - Name string `json:"name"` - ToolName string `json:"toolName"` - Arguments json.RawMessage `json:"arguments"` + callID, err := decodePiStringAliases(payload, "Pi 工具调用 ID", "callId", "call_id", "toolCallId", "tool_call_id", "id") + if err != nil { + return nil, err } - if err := decodePiPayload(payload, &request); err != nil { + if callID == "" { + return nil, errors.New("Pi 工具调用缺少 call id") + } + name, err := decodePiStringAliases(payload, "Pi 工具名称", "name", "toolName") + if err != nil { return nil, err } var call cloudAgentCall - call.ID = firstNonEmpty(request.CallID, generateID()) - call.Function.Name = firstNonEmpty(request.Name, request.ToolName) - call.Function.Arguments = strings.TrimSpace(string(request.Arguments)) + call.ID = callID + call.Function.Name = name + if raw, exists := payload["arguments"]; exists { + call.Function.Arguments = strings.TrimSpace(string(raw)) + } if call.Function.Arguments == "" || call.Function.Arguments == "null" { call.Function.Arguments = "{}" } if call.Function.Name == "" { return nil, fmt.Errorf("tool call is missing a name") } + if err := validateCloudAgentCalls([]cloudAgentCall{call}); err != nil { + return nil, err + } // 工具白名单校验:拒绝未声明的工具调用 run, err := s.repo.CloudAgent(userID, runID) @@ -221,18 +367,29 @@ func (s *Service) executeCloudAgentRuntimeTool(ctx context.Context, userID, runI if err != nil { return nil, err } - if cloudAgentRunTerminal(run.Status) { - return nil, fmt.Errorf("run already terminated") - } state, err := cloudAgentDecode(run) if err != nil { return nil, err } + // A runtime may retry the same /tool request after the bridge response was + // lost. Replay the committed result before inspecting CallIndex: the + // executor may already have advanced to the next call or completed the run. + if content, ok := cloudAgentToolMessage(&state, call.ID); ok { + return cloudAgentPiToolResult(call, content), nil + } + if cloudAgentRunTerminal(run.Status) { + return nil, fmt.Errorf("run already terminated") + } if state.StepLimits, err = s.cloudAgentStepLimits(); err != nil { return nil, err } - state.Calls = []cloudAgentCall{call} - state.CallIndex = 0 + if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { + return nil, fmt.Errorf("工具调用 %q 没有对应的模型工具调用", call.ID) + } + expected := state.Calls[state.CallIndex] + if !cloudAgentToolCallsEquivalent(expected, call) { + return nil, fmt.Errorf("工具调用 %q 与模型声明的工具调用不一致", call.ID) + } state.Approval = nil err = s.advanceCloudAgentTool(run, &state) if errors.Is(err, repository.ErrCreationConflict) { @@ -264,18 +421,7 @@ func (s *Service) executeCloudAgentRuntimeTool(ctx context.Context, userID, runI return nil, fmt.Errorf("%s", firstNonEmpty(run.FailureMessage, "Agent 工具执行失败")) } if content, ok := cloudAgentToolMessage(&state, call.ID); ok { - if trimmed := strings.TrimSpace(content); trimmed == "" || trimmed == "null" { - content = `{"error":"工具没有返回结果"}` - } - result := map[string]any{"content": content, "isError": cloudAgentToolContentIsError(content)} - if call.Function.Name == "ask_user" { - var payload map[string]any - if json.Unmarshal([]byte(content), &payload) == nil && stringValue(payload["phase"]) == "question" { - // ask_user 已经把本轮交给用户,Pi 不能再发起下一次模型调用。 - result["terminate"] = true - } - } - return result, nil + return cloudAgentPiToolResult(call, content), nil } if state.MediaTaskID == "" { return nil, fmt.Errorf("tool %s produced no result", call.Function.Name) @@ -289,6 +435,39 @@ func (s *Service) executeCloudAgentRuntimeTool(ctx context.Context, userID, runI } } +func cloudAgentToolCallsEquivalent(expected, received cloudAgentCall) bool { + if expected.ID == "" || expected.ID != received.ID || expected.Function.Name != received.Function.Name { + return false + } + var expectedArgs, receivedArgs map[string]interface{} + if err := json.Unmarshal([]byte(expected.Function.Arguments), &expectedArgs); err != nil || expectedArgs == nil { + return false + } + if err := json.Unmarshal([]byte(received.Function.Arguments), &receivedArgs); err != nil || receivedArgs == nil { + return false + } + left, err := json.Marshal(expectedArgs) + if err != nil { + return false + } + right, err := json.Marshal(receivedArgs) + return err == nil && string(left) == string(right) +} + +func cloudAgentPiToolResult(call cloudAgentCall, content string) map[string]any { + if trimmed := strings.TrimSpace(content); trimmed == "" || trimmed == "null" { + content = `{"error":"工具没有返回结果"}` + } + result := map[string]any{"content": content, "isError": cloudAgentToolContentIsError(content)} + if call.Function.Name == "ask_user" { + var payload map[string]any + if json.Unmarshal([]byte(content), &payload) == nil && stringValue(payload["phase"]) == "question" { + result["terminate"] = true + } + } + return result +} + // settleCloudAgentMedia 在媒体任务结束后用唯一的业务执行器回写画布、记录工具结果并释放 // MediaTaskID。任务仍在生成时直接返回;并发写冲突时重读重试。 func (s *Service) settleCloudAgentMedia(userID, runID string) error { @@ -366,6 +545,15 @@ func (s *Service) cloudAgentPiEvent(ctx context.Context, userID, runID string, p return s.handleMessageEnd(userID, runID, data) case "session_snapshot": return s.handlePiSessionSnapshot(userID, runID, data) + case "compaction_start", "compaction_end": + kind, eventPayload, ok := cloudAgentPiCompactionEvent(eventType, data) + if !ok { + return map[string]any{"ok": true}, nil + } + if err := s.broadcastAgentEvent(userID, runID, kind, eventPayload); err != nil { + return nil, err + } + return map[string]any{"ok": true}, nil case "tool_call", "tool_call_start", "tool_call_end": return s.handleToolCall(userID, runID, data) case "error": @@ -375,6 +563,57 @@ func (s *Service) cloudAgentPiEvent(ctx context.Context, userID, runID string, p } } +func cloudAgentPiCompactionEvent(eventType string, data map[string]any) (string, map[string]any, bool) { + reason := stringField(data, "reason") + contextUsage, _ := data["contextUsage"].(map[string]any) + switch eventType { + case "compaction_start": + payload := map[string]any{ + "basis": "tokens", + "reason": reason, + "contextUsage": contextUsage, + } + if compactionID := strings.TrimSpace(stringField(data, "compactionId")); compactionID != "" { + payload["compactionId"] = compactionID + } + return "context_compaction_requested", payload, true + case "compaction_end": + payload := map[string]any{ + "reason": reason, + "aborted": data["aborted"], + "willRetry": data["willRetry"], + "contextUsage": contextUsage, + } + if compactionID := strings.TrimSpace(stringField(data, "compactionId")); compactionID != "" { + payload["compactionId"] = compactionID + } + for _, key := range []string{"tokensBefore", "estimatedTokensAfter"} { + if value, exists := data[key]; exists { + payload[key] = value + } + } + aborted, _ := data["aborted"].(bool) + hasResult, _ := data["hasResult"].(bool) + errorMessage := strings.TrimSpace(stringField(data, "errorMessage")) + if hasResult && !aborted && errorMessage == "" { + return "context_compacted", payload, true + } + payload["cancelled"] = aborted + if aborted { + payload["text"] = "上下文压缩已取消,原有对话历史已保留" + } else { + if errorMessage == "" { + errorMessage = "Pi 未能生成上下文摘要" + } + payload["errorMessage"] = truncateRunes(errorMessage, 1000) + payload["text"] = "上下文压缩失败,原有对话历史已保留:" + truncateRunes(errorMessage, 1000) + } + return "context_compaction_failed", payload, true + default: + return "", nil, false + } +} + // handlePiSessionSnapshot 持久化 Pi 原生会话 JSONL,用于审批恢复和续轮。 func (s *Service) handlePiSessionSnapshot(userID, runID string, data map[string]any) (any, error) { sessionJSONL, _ := data["sessionJSONL"].(string) @@ -411,6 +650,22 @@ func stateCanonicalFromInput(input map[string]any) canonicalAgentRequest { return canonical } +func canonicalAgentRequestFromInput(input map[string]any) (canonicalAgentRequest, bool) { + requests, ok := input["agentRequests"].(map[string]any) + if !ok || requests["canonical"] == nil { + return canonicalAgentRequest{}, false + } + raw, err := json.Marshal(requests["canonical"]) + if err != nil { + return canonicalAgentRequest{}, false + } + var canonical canonicalAgentRequest + if err := json.Unmarshal(raw, &canonical); err != nil { + return canonicalAgentRequest{}, false + } + return canonical, true +} + func piToolDefinitions(source []map[string]interface{}) []map[string]any { result := make([]map[string]any, 0, len(source)) for _, tool := range source { @@ -449,11 +704,19 @@ func decodePiPayload(payload map[string]json.RawMessage, target interface{}) err return fmt.Errorf("failed to decode payload") } +func decodePiStringAliases(payload map[string]json.RawMessage, label string, aliases ...string) (string, error) { + fields := make(map[string]interface{}, len(payload)) + for key, raw := range payload { + var value interface{} + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("decode Pi field %q: %w", key, err) + } + fields[key] = value + } + return strictRuntimeStringAliases(fields, label, aliases...) +} + func mustMarshal(v any) string { data, _ := json.Marshal(v) return string(data) } - -func generateID() string { - return fmt.Sprintf("%d", time.Now().UnixNano()) -} diff --git a/backend/internal/app/cloud_agent_pi_helpers.go b/backend/internal/app/cloud_agent_pi_helpers.go index 34b876796..7fbcb713f 100644 --- a/backend/internal/app/cloud_agent_pi_helpers.go +++ b/backend/internal/app/cloud_agent_pi_helpers.go @@ -36,14 +36,21 @@ func (s *Service) buildSkillManifests(skills []cloudAgentSkill) []map[string]any } // buildCompactionStrategy 构建压缩策略 -func (s *Service) buildCompactionStrategy(userID, canvasID string) map[string]any { +func (s *Service) buildCompactionStrategy(userID, canvasID string, budgets ...cloudAgentContextBudget) map[string]any { + budget := defaultCloudAgentContextBudget() + if len(budgets) > 0 { + budget = budgets[0] + } + compactAt := budget.CompactAtTokens + if compactAt <= 0 || compactAt >= budget.ContextWindowTokens { + compactAt = budget.ContextWindowTokens * 80 / 100 + } + keepRecentTokens := min(20_000, max(1_024, budget.ContextWindowTokens/6)) + keepRecentTokens = min(keepRecentTokens, max(512, compactAt/2)) return map[string]any{ "enabled": true, - "strategy": "balanced", // balanced/aggressive/conservative - "reserveTokens": 2048, - "prioritizeRecent": true, - "keepSystemPrompt": true, - "keepToolResults": true, + "reserveTokens": budget.ContextWindowTokens - compactAt, + "keepRecentTokens": keepRecentTokens, } } diff --git a/backend/internal/app/cloud_agent_pi_model.go b/backend/internal/app/cloud_agent_pi_model.go index 4e51fb496..eb86aadcb 100644 --- a/backend/internal/app/cloud_agent_pi_model.go +++ b/backend/internal/app/cloud_agent_pi_model.go @@ -19,20 +19,87 @@ import ( "infinite-canvas/backend/internal/repository" ) +const cloudAgentCompactionSummarySystemPromptPrefix = "You are a context summarization assistant." + +type cloudAgentPiModelRequest struct { + ModelID string `json:"modelId"` + SystemPrompt string `json:"systemPrompt"` + Messages []map[string]any `json:"messages"` + Tools []map[string]any `json:"tools"` + ThinkingLevel string `json:"thinkingLevel"` + Purpose string `json:"purpose"` + ContextUsage *cloudAgentPiContextUsage `json:"contextUsage"` +} + +// cloudAgentPiContextUsage 是 Pi SDK 对当前会话上下文的原生读数。 +// tokens/percent 使用指针以保留 Pi 在压缩后返回 null 的语义:没有新读数时, +// Go 不得用本地估算或旧值冒充当前占用。 +type cloudAgentPiContextUsage struct { + Tokens *int `json:"tokens"` + ContextWindow int `json:"contextWindow"` + Percent *float64 `json:"percent"` +} + +func (s *Service) validateCloudAgentPiModelRequest(userID, runID string, request cloudAgentPiModelRequest) (bool, error) { + compactionSummary := cloudAgentPiCompactionSummaryRequest(request.Messages) + purpose := strings.TrimSpace(request.Purpose) + switch purpose { + case "": + if compactionSummary { + purpose = "compaction" + } else { + purpose = "conversation" + } + case "conversation": + if compactionSummary { + return false, errors.New("普通 Agent 模型请求不能携带压缩摘要上下文") + } + case "compaction": + if !compactionSummary { + return false, errors.New("压缩模型请求缺少压缩摘要系统提示") + } + default: + return false, fmt.Errorf("未知的 Pi 模型请求 purpose: %s", purpose) + } + if purpose == "compaction" && len(request.Tools) > 0 { + return false, errors.New("压缩模型请求不得携带 Agent 工具") + } + if strings.TrimSpace(request.ModelID) == "" { + return compactionSummary, nil + } + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return false, err + } + state, err := cloudAgentDecode(run) + if err != nil { + return false, err + } + expected := strings.TrimSpace(firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)) + if expected != "" && request.ModelID != expected { + return false, fmt.Errorf("Pi 模型标识与运行配置不一致: got %q, want %q", request.ModelID, expected) + } + return compactionSummary, nil +} + // cloudAgentPiModel 模型调用桥接 func (s *Service) cloudAgentPiModel(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { - var request struct { - Messages []map[string]any `json:"messages"` - Tools []map[string]any `json:"tools"` - ThinkingLevel string `json:"thinkingLevel"` - } + var request cloudAgentPiModelRequest if err := decodePiPayload(payload, &request); err != nil { return nil, err } + compactionSummary, err := s.validateCloudAgentPiModelRequest(userID, runID, request) + if err != nil { + return nil, err + } if len(request.Messages) == 0 { return nil, fmt.Errorf("no messages") } - + if !compactionSummary { + if err := s.broadcastCloudAgentPiContextPressure(userID, runID, request.ContextUsage); err != nil { + return nil, err + } + } // 上游临时故障(5xx、429、超时、连接错误、空回复)自动重试,首次失败后最多再试 3 次, // 指数退避。参数/鉴权类 4xx 重试也不会变好,直接失败。 // 模型吐出损坏的工具参数 JSON(截断或多一个括号)时,那次调用并没有被执行: @@ -65,6 +132,32 @@ func (s *Service) cloudAgentPiModel(ctx context.Context, userID, runID string, p return nil, lastErr } +// broadcastCloudAgentPiContextPressure 只转发 Pi SDK 的会话读数。 +// 这是上下文占用的唯一对外口径;Go 不估算正文 Token,也不再用 API usage 锚点校准。 +func (s *Service) broadcastCloudAgentPiContextPressure(userID, runID string, usage *cloudAgentPiContextUsage) error { + if usage == nil || usage.Tokens == nil || usage.ContextWindow <= 0 { + return nil + } + + ratio := float64(*usage.Tokens) / float64(usage.ContextWindow) + if usage.Percent != nil { + ratio = *usage.Percent / 100 + } + payload := map[string]any{ + "estimatedInputTokens": *usage.Tokens, + "contextWindowTokens": usage.ContextWindow, + "usableInputTokens": usage.ContextWindow, + "compactAtTokens": usage.ContextWindow * 80 / 100, + "pressureRatio": ratio, + "modelLimitConfigured": true, + "estimate": true, + "tokenSource": "pi", + "estimateMethod": "pi-sdk", + "readingScope": "pi-session", + } + return s.broadcastAgentEvent(userID, runID, "context_pressure", payload) +} + // cloudAgentModelStepRetries 是单步模型调用首次失败后的最大重试次数。 const cloudAgentModelStepRetries = 3 @@ -74,6 +167,7 @@ var errCloudAgentTruncatedToolArguments = errors.New("truncated tool arguments") // runCloudAgentModelStep 调度并等待一次模型步骤;第二个返回值表示失败是否值得重试。 func (s *Service) runCloudAgentModelStep(ctx context.Context, userID, runID string, messages []map[string]any, thinkingLevel string, correction ...map[string]any) (any, bool, error) { + compactionSummary := cloudAgentPiCompactionSummaryRequest(messages) // 运行时发起 /model 的同时会并发推送事件(message_start、session_snapshot 等), // 这些事件也会推进运行 revision。调度模型步骤是 CAS 写入:冲突时重新读取 // 最新状态再调度,而不是把整轮判失败。冲突时事务整体回滚,不会产生任务或扣费。 @@ -121,19 +215,16 @@ func (s *Service) runCloudAgentModelStep(ctx context.Context, userID, runID stri continue } - canonical := canonicalFromRuntimeMessages(messages, state.Canonical.Tools, state.Canonical.SystemPrompt) - // A continuation starts a fresh Pi process. If its session snapshot was - // unavailable, the runtime transcript is shorter than the server-authored - // canonical history. Reuse canonical directly so provider tool_calls are - // not accidentally converted as if they were Pi content blocks. - if len(state.Canonical.Messages) > len(messages) { - canonical = state.Canonical + canonical, historyErr := cloudAgentPiCanonicalForModelRequest(messages, state.Canonical) + if historyErr != nil { + return nil, false, historyErr } - canonical.PromptCacheKey = state.Canonical.PromptCacheKey if len(canonical.Messages) == 0 { return nil, false, fmt.Errorf("no canonical messages") } - state.Canonical = canonical + if !compactionSummary { + state.Canonical = canonical + } if len(correction) > 0 { // 纠偏上下文只用于这一次请求,不写回运行状态,避免下一步重复出现。 canonical.Messages = append(append([]map[string]any(nil), canonical.Messages...), correction...) @@ -206,7 +297,26 @@ func (s *Service) runCloudAgentModelStep(ctx context.Context, userID, runID stri if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil { return nil, false, fmt.Errorf("decode model result: %w", err) } - if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, result.Text, result.Reasoning); err != nil { + finishText, finishReasoning := result.Text, result.Reasoning + if compactionSummary { + finishText, finishReasoning = "", "" + } + if !compactionSummary { + if err := validateCloudAgentCalls(result.ToolCalls); err != nil { + // The task itself succeeded, but its protocol result is not executable. + // Release the adopted task before returning the hard protocol error so a + // restarted run cannot remain stuck behind an invalid active task. + if releaseErr := s.finishCloudAgentPiModelStep(userID, runID, task.ID, "", ""); releaseErr != nil { + return nil, false, releaseErr + } + return nil, false, fmt.Errorf("invalid Pi tool calls: %w", err) + } + } + if compactionSummary { + if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, "", ""); err != nil { + return nil, false, err + } + } else if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, finishText, finishReasoning, result.ToolCalls); err != nil { return nil, false, err } return map[string]any{"text": result.Text, "reasoning": result.Reasoning, "toolCalls": runtimeToolCalls(result.ToolCalls)}, false, nil @@ -238,6 +348,46 @@ func (s *Service) adoptCloudAgentPiModelStep(userID, runID, taskID string) (adop return false, false, nil } +func cloudAgentPiCanonicalForModelRequest(messages []map[string]any, existing canonicalAgentRequest) (canonicalAgentRequest, error) { + compactionSummaryRequest := cloudAgentPiCompactionSummaryRequest(messages) + tools, systemPrompt := existing.Tools, existing.SystemPrompt + if compactionSummaryRequest { + tools, systemPrompt = nil, "" + } + canonical, conversionErr := canonicalFromRuntimeMessages(messages, tools, systemPrompt) + if conversionErr != nil { + return canonicalAgentRequest{}, conversionErr + } + if compactionSummaryRequest { + return canonical, nil + } + if err := validateRuntimeMessagesForModel(messages); err != nil { + if len(existing.Messages) == 0 { + return canonicalAgentRequest{}, err + } + return existing, nil + } + // A projected Pi transcript containing a compaction summary is authoritative, + // even though it is shorter than the server's pre-compaction canonical history. + if !cloudAgentPiHasCompactionSummary(messages) && len(existing.Messages) > len(messages) { + return existing, nil + } + canonical.PromptCacheKey = existing.PromptCacheKey + return canonical, nil +} + +func cloudAgentPiCompactionSummaryRequest(messages []map[string]any) bool { + for _, message := range messages { + if stringField(message, "role") != "system" { + continue + } + if strings.HasPrefix(strings.TrimSpace(runtimeContentText(message["content"])), cloudAgentCompactionSummarySystemPromptPrefix) { + return true + } + } + return false +} + // cloudAgentPiThinkingEnabled treats an omitted thinking level the same as Pi's // explicit "off" value. Pi omits the field when thinking is disabled, and the // Go JSON decoder represents that omitted field as an empty string. @@ -245,9 +395,36 @@ func cloudAgentPiThinkingEnabled(thinkingLevel string) bool { return thinkingLevel != "" && thinkingLevel != "off" } -// cloudAgentModelTaskRetryable 按上游真实 HTTP 状态判断失败是否是临时性的: -// 5xx、429、408 与没有拿到任何响应的网络错误可以重试;空回复也可以重试。 -// 其余 4xx(参数、鉴权、模型不存在)重试结果不会变,直接失败。 +// cloudAgentTransientModelFailure recognizes providers that returned a business +// error envelope instead of a useful HTTP status. Some gateways return 200 with +// codes such as openai_error/api_error; those failures are still safe to retry +// because the model task has not produced an assistant result yet. +func cloudAgentTransientModelFailure(call model.ApiCallLog) bool { + if call.Status != model.ApiCallStatusFailed { + return false + } + // A permanent HTTP 4xx must win over a generic business-error label. The + // caller handles 429 separately as an explicitly retryable rate limit. + if call.StatusCode >= 400 && call.StatusCode < 500 { + return false + } + text := strings.ToLower(strings.TrimSpace(call.ErrorCode + " " + call.Error)) + for _, marker := range []string{"invalid_request", "authentication", "permission", "forbidden", "model_not_found", "insufficient_quota", "content_policy", "sensitive_words", "safety"} { + if strings.Contains(text, marker) { + return false + } + } + for _, marker := range []string{"openai_error", "api_error", "server_error", "temporarily_unavailable", "service_unavailable", "overloaded", "gateway_error", "rate_limit", "too_many_requests", "upstream_timeout"} { + if strings.Contains(text, marker) { + return true + } + } + return false +} + +// cloudAgentModelTaskRetryable 按上游真实 HTTP 状态和结构化业务错误判断失败是否是临时性的: +// 5xx、429、408、明确的临时业务错误和没有拿到任何响应的网络错误可以重试; +// 参数、鉴权、模型不存在和内容审核错误不会重试。 func (s *Service) cloudAgentModelTaskRetryable(taskID string) bool { task, err := s.repo.Task(taskID) if err != nil || task == nil || task.Status == model.TaskStatusSucceeded || task.Status == model.TaskStatusCancelled { @@ -256,10 +433,14 @@ func (s *Service) cloudAgentModelTaskRetryable(taskID string) bool { if cloudAgentEmptyModelOutput(task) || cloudAgentStepTimedOut(task) { return true } - status, err := s.repo.LatestAPICallStatusForTask(taskID) + call, err := s.repo.LatestAPICallForTask(taskID) if err != nil { return false } + if cloudAgentTransientModelFailure(call) { + return true + } + status := call.StatusCode switch { case status >= 500, status == 429, status == 408: return true @@ -271,9 +452,17 @@ func (s *Service) cloudAgentModelTaskRetryable(taskID string) bool { return false } -// finishCloudAgentPiModelStep 释放已完成的模型步骤,并把最终正文写入 Agent 事件流。 +// finishCloudAgentPiModelStep 释放已完成的模型步骤,并把模型结果提交到运行检查点。 +// calls 只在普通会话模型调用中传入;压缩摘要调用不应污染普通 canonical 历史、工具批次或步数。 // 不释放 ActiveTaskID 的话,completeCloudAgentPiRun 会一直认为还有任务在跑。 -func (s *Service) finishCloudAgentPiModelStep(userID, runID, taskID, text, reasoning string) error { +func (s *Service) finishCloudAgentPiModelStep(userID, runID, taskID, text, reasoning string, callBatches ...[]cloudAgentCall) error { + var calls []cloudAgentCall + if len(callBatches) > 0 { + calls = callBatches[0] + if err := validateCloudAgentCalls(calls); err != nil { + return err + } + } for attempt := 0; attempt < 8; attempt++ { run, err := s.repo.CloudAgent(userID, runID) if err != nil { @@ -292,9 +481,24 @@ func (s *Service) finishCloudAgentPiModelStep(userID, runID, taskID, text, reaso if reasoning != "" { fresh.event(runID, "reasoning_message", map[string]any{"messageId": taskID + ":reasoning", "text": truncateRunes(reasoning, 8000)}) } + if len(callBatches) > 0 { + fresh.Canonical.ToolChoice = "auto" + fresh.Calls = append([]cloudAgentCall(nil), calls...) + fresh.CallIndex = 0 + fresh.StepSnapshotHash = cloudAgentCaptureStepSnapshotHash(calls) + } if text != "" { fresh.event(runID, "assistant_message", map[string]any{"messageId": taskID, "text": text}) } + if len(callBatches) > 0 { + if len(calls) > 0 { + fresh.Canonical.Messages = append(fresh.Canonical.Messages, map[string]any{ + "role": "assistant", "content": text, "tool_calls": calls, + }) + } else if text != "" { + fresh.Canonical.Messages = append(fresh.Canonical.Messages, map[string]any{"role": "assistant", "content": text}) + } + } return cloudAgentSave(current, &fresh) }) if !errors.Is(err, repository.ErrCreationConflict) { diff --git a/backend/internal/app/cloud_agent_pi_model_test.go b/backend/internal/app/cloud_agent_pi_model_test.go new file mode 100644 index 000000000..b3c495c61 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_model_test.go @@ -0,0 +1,277 @@ +package app + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestCloudAgentPiCompactionSummaryUsesStandardStepWithoutAssistantEvent(t *testing.T) { + s, db, _ := agentMediaFixture(t) + s.disablePiRuntime = true + root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "") + if err != nil { + t.Fatal(err) + } + initialRun, err := s.repo.CloudAgent("user", root.ID) + if err != nil { + t.Fatal(err) + } + initialState, err := cloudAgentDecode(initialRun) + if err != nil { + t.Fatal(err) + } + + messages := []map[string]any{ + {"role": "system", "content": []any{map[string]any{"type": "text", "text": cloudAgentCompactionSummarySystemPromptPrefix + " Summarize the conversation."}}}, + {"role": "user", "content": []any{map[string]any{"type": "text", "text": "Earlier context"}}}, + } + encodedMessages, err := json.Marshal(messages) + if err != nil { + t.Fatal(err) + } + payload := map[string]json.RawMessage{ + "messages": encodedMessages, + "thinkingLevel": json.RawMessage(`"off"`), + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + type outcome struct { + result any + err error + } + done := make(chan outcome, 1) + go func() { + result, stepErr := s.cloudAgentPiModel(ctx, "user", root.ID, payload) + done <- outcome{result: result, err: stepErr} + }() + + var taskID string + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + stored, readErr := s.repo.CloudAgent("user", root.ID) + if readErr != nil { + t.Fatal(readErr) + } + state, decodeErr := cloudAgentDecode(stored) + if decodeErr != nil { + t.Fatal(decodeErr) + } + if state.ActiveTaskID != "" && state.ActiveTaskID != initialState.ActiveTaskID { + taskID = state.ActiveTaskID + break + } + select { + case got := <-done: + t.Fatalf("summary model bridge returned before scheduling: result=%v err=%v", got.result, got.err) + case <-time.After(25 * time.Millisecond): + } + } + if taskID == "" { + t.Fatal("summary model step was never scheduled") + } + + var task model.Task + if err := db.First(&task, "id = ?", taskID).Error; err != nil { + t.Fatal(err) + } + if task.Operation != cloudAgentStepOperation { + t.Fatalf("summary operation = %q, want standard %q", task.Operation, cloudAgentStepOperation) + } + var input map[string]any + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + t.Fatalf("decode scheduled model input: %v", err) + } + agentRequests, _ := input["agentRequests"].(map[string]any) + canonical, _ := agentRequests["canonical"].(map[string]any) + canonicalMessages, _ := canonical["messages"].([]any) + if len(canonicalMessages) == 0 { + t.Fatalf("scheduled summary request has no canonical messages: %#v", canonical) + } + firstMessage, _ := canonicalMessages[0].(map[string]any) + if stringField(firstMessage, "role") != "system" || !strings.HasPrefix(stringField(firstMessage, "content"), cloudAgentCompactionSummarySystemPromptPrefix) { + t.Fatalf("Pi summary system prompt was not preserved: %#v", firstMessage) + } + + if err := db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]any{ + "status": model.TaskStatusSucceeded, + "result_json": `{"text":"internal compaction summary","toolCalls":[]}`, + }).Error; err != nil { + t.Fatal(err) + } + select { + case got := <-done: + if got.err != nil { + t.Fatalf("summary model bridge failed: %v", got.err) + } + result, _ := got.result.(map[string]any) + if result["text"] != "internal compaction summary" { + t.Fatalf("Pi did not receive summary: %#v", got.result) + } + case <-ctx.Done(): + t.Fatal("summary model bridge did not return") + } + + stored, err := s.repo.CloudAgent("user", root.ID) + if err != nil { + t.Fatal(err) + } + state, err := cloudAgentDecode(stored) + if err != nil { + t.Fatal(err) + } + if state.ActiveTaskID != "" { + t.Fatalf("summary step remained active: %q", state.ActiveTaskID) + } + if state.Step != initialState.Step+1 { + t.Fatalf("summary step = %d, want %d", state.Step, initialState.Step+1) + } + if !reflect.DeepEqual(state.Canonical, initialState.Canonical) { + t.Fatal("internal summary replaced canonical conversation") + } + if state.PiAssistantResponses != initialState.PiAssistantResponses { + t.Fatalf("internal summary changed response count %d → %d", initialState.PiAssistantResponses, state.PiAssistantResponses) + } + for _, event := range state.Events { + if event.Type == "assistant_message" && stringField(event.Payload, "text") == "internal compaction summary" { + t.Fatal("internal summary was published as assistant message") + } + } +} + +func TestCloudAgentPiCompactedProjectionPreservesSummaryAndRecentToolResult(t *testing.T) { + messages := []map[string]any{ + {"role": "compactionSummary", "summary": "Earlier decisions", "tokensBefore": 12000}, + {"role": "assistant", "content": []any{map[string]any{ + "type": "toolCall", "id": "call-recent", "name": "canvas_read", "arguments": map[string]any{"nodeId": "node-1"}, + }}}, + {"role": "toolResult", "toolCallId": "call-recent", "content": []any{map[string]any{"type": "text", "text": "recent tool result"}}}, + {"role": "user", "content": "Continue from the current state."}, + } + existing := canonicalAgentRequest{ + Tools: []map[string]interface{}{{"type": "function", "function": map[string]interface{}{"name": "canvas_read"}}}, + SystemPrompt: "Agent system prompt", + PromptCacheKey: "cache-key", + Messages: make([]map[string]interface{}, 8), + } + + canonical, err := cloudAgentPiCanonicalForModelRequest(messages, existing) + if err != nil { + t.Fatalf("convert compacted projection: %v", err) + } + if len(canonical.Messages) != 5 { + t.Fatalf("compacted projection messages = %d, want 5: %#v", len(canonical.Messages), canonical.Messages) + } + if stringField(canonical.Messages[1], "role") != "user" || !strings.Contains(stringField(canonical.Messages[1], "content"), "Earlier decisions") { + t.Fatalf("compaction summary not converted: %#v", canonical.Messages[1]) + } + if stringField(canonical.Messages[3], "role") != "tool" || stringField(canonical.Messages[3], "content") != "recent tool result" { + t.Fatalf("recent tool result not retained: %#v", canonical.Messages[3]) + } + if canonical.PromptCacheKey != "cache-key" { + t.Fatalf("lost prompt cache key: %q", canonical.PromptCacheKey) + } +} + +func TestCloudAgentPiCompactionSummarySkipsOrdinaryTranscriptValidation(t *testing.T) { + messages := []map[string]any{ + {"role": "system", "content": cloudAgentCompactionSummarySystemPromptPrefix}, + {"role": "assistant", "content": []any{map[string]any{ + "type": "toolCall", "id": "unfinished", "name": "canvas_read", "arguments": map[string]any{}, + }}}, + {"role": "user", "content": "Serialized conversation."}, + } + canonical, err := cloudAgentPiCanonicalForModelRequest(messages, canonicalAgentRequest{ + Tools: []map[string]interface{}{{"type": "function"}}, + SystemPrompt: "ordinary Agent system prompt", + }) + if err != nil { + t.Fatalf("summary request failed validation: %v", err) + } + if len(canonical.Tools) != 0 { + t.Fatalf("summary request included Agent tools: %#v", canonical.Tools) + } + if canonical.SystemPrompt != "" || stringField(canonical.Messages[0], "content") != cloudAgentCompactionSummarySystemPromptPrefix { + t.Fatalf("summary system prompt not isolated: %#v", canonical) + } +} + +func TestCloudAgentPiContextPressureForwardsPiUsage(t *testing.T) { + s, _, _ := agentMediaFixture(t) + root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "") + if err != nil { + t.Fatal(err) + } + tokens, percent := 120_000, 93.75 + if err := s.broadcastCloudAgentPiContextPressure("user", root.ID, &cloudAgentPiContextUsage{ + Tokens: &tokens, + ContextWindow: 128_000, + Percent: &percent, + }); err != nil { + t.Fatal(err) + } + + stored, err := s.repo.CloudAgent("user", root.ID) + if err != nil { + t.Fatal(err) + } + state, err := cloudAgentDecode(stored) + if err != nil { + t.Fatal(err) + } + if len(state.Events) == 0 || state.Events[len(state.Events)-1].Type != "context_pressure" { + t.Fatalf("Pi context pressure event missing: %#v", state.Events) + } + payload := state.Events[len(state.Events)-1].Payload + if payload["tokenSource"] != "pi" || payload["estimateMethod"] != "pi-sdk" || payload["readingScope"] != "pi-session" { + t.Fatalf("unexpected Pi context source metadata: %#v", payload) + } + if got := payload["estimatedInputTokens"]; got != float64(tokens) { + t.Fatalf("estimatedInputTokens = %#v, want %d", got, tokens) + } + if got := payload["pressureRatio"]; got != percent/100 { + t.Fatalf("pressureRatio = %#v, want %v", got, percent/100) + } +} + +func TestCloudAgentTransientModelFailureDoesNotOverridePermanentHTTPStatus(t *testing.T) { + cases := []struct { + name string + call model.ApiCallLog + want bool + }{ + { + name: "http 200 business error is transient", + call: model.ApiCallLog{Status: model.ApiCallStatusFailed, StatusCode: 200, ErrorCode: "api_error"}, + want: true, + }, + { + name: "http 400 business error is permanent", + call: model.ApiCallLog{Status: model.ApiCallStatusFailed, StatusCode: 400, ErrorCode: "api_error"}, + want: false, + }, + { + name: "http 401 authentication error is permanent", + call: model.ApiCallLog{Status: model.ApiCallStatusFailed, StatusCode: 401, ErrorCode: "authentication"}, + want: false, + }, + { + name: "successful call is never transient", + call: model.ApiCallLog{Status: model.ApiCallStatusSucceeded, StatusCode: 200, ErrorCode: "api_error"}, + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := cloudAgentTransientModelFailure(tc.call); got != tc.want { + t.Fatalf("cloudAgentTransientModelFailure() = %v, want %v for %#v", got, tc.want, tc.call) + } + }) + } +} diff --git a/backend/internal/app/cloud_agent_pi_request.go b/backend/internal/app/cloud_agent_pi_request.go index 24c0ac932..2bc4f37db 100644 --- a/backend/internal/app/cloud_agent_pi_request.go +++ b/backend/internal/app/cloud_agent_pi_request.go @@ -68,7 +68,8 @@ func (s *Service) buildEnhancedPiRequest(ctx context.Context, params EnhancedPiR features := s.buildFeaturesConfig(params.RuntimeState) // 7. 构建压缩策略 - compaction := s.buildCompactionStrategy(params.UserID, params.CanvasID) + contextBudget := s.cloudAgentContextBudgetForRequest(params.RuntimeState.Request) + compaction := s.buildCompactionStrategy(params.UserID, params.CanvasID, contextBudget) // 8. 构建权限配置 permissions := s.buildPermissionsConfig(params.UserID, params.CanvasID) @@ -238,13 +239,14 @@ func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any // buildModelConfig 构建模型配置 func (s *Service) buildModelConfig(modelID string, state *cloudAgentRuntime) map[string]any { + budget := s.cloudAgentContextBudgetForRequest(state.Request) return map[string]any{ "id": modelID, "name": modelID, "reasoning": cloudAgentReasoningEnabled(state.Policy.ReasoningMode), "input": []string{"text", "image"}, - "contextWindow": 200000, - "maxTokens": 8192, + "contextWindow": budget.ContextWindowTokens, + "maxTokens": budget.MaxOutputTokens, "provider": state.Request.ChannelID, "switchable": true, "temperature": 0.7, diff --git a/backend/internal/app/cloud_agent_policy.go b/backend/internal/app/cloud_agent_policy.go index af36889c6..3039a0af4 100644 --- a/backend/internal/app/cloud_agent_policy.go +++ b/backend/internal/app/cloud_agent_policy.go @@ -27,52 +27,6 @@ type cloudAgentPolicySnapshot struct { CompilerVersion string `json:"compilerVersion"` ProfileRevision string `json:"profileRevision,omitempty"` ProfileHash string `json:"profileHash,omitempty"` - // SystemSegments 记录编译出来的系统提示由哪些块组成。压力读数把它摊开上报, - // 排查时不必再从提示词正文反推"系统提示里谁是画布摘要、谁是个人记忆"。 - SystemSegments []cloudAgentContextSegment `json:"systemSegments,omitempty"` -} - -// cloudAgentContextSegment 是系统提示里的一块(编译期按写入顺序量出来的)。 -type cloudAgentContextSegment struct { - Key string `json:"key"` - Label string `json:"label"` - Bytes int `json:"bytes"` - Tokens int `json:"tokens"` - // ScaledTokens 是按上游实测锚点比例校准后的读数;没有锚点时与 Tokens 相同。 - ScaledTokens int `json:"scaledTokens,omitempty"` -} - -// cloudAgentSegmentRecorder 在编译系统提示时逐块量尺寸:它只记录"上一次标记之后追加了 -// 多少字节",因此分段合计与最终 system 正文逐字节一致(含块之间的标题与分隔符)。 -type cloudAgentSegmentRecorder struct { - segments []cloudAgentContextSegment - last int -} - -func (r *cloudAgentSegmentRecorder) mark(builder *strings.Builder, key, label string) { - end := builder.Len() - if size := end - r.last; size > 0 { - text := builder.String()[r.last:end] - r.segments = append(r.segments, cloudAgentContextSegment{Key: key, Label: label, Bytes: size, Tokens: estimateCloudAgentTokens([]byte(text))}) - } - r.last = end -} - -// cloudAgentRecordSystemSegment 登记"编译之后"追加进系统提示的块(当前是个人记忆索引), -// 让分段合计与实际 system 桶一致;同 key 重复调用只保留最新一次,因此它既能在创建运行时 -// 登记,也能在每一步幂等补登记。 -func cloudAgentRecordSystemSegment(policy *cloudAgentPolicySnapshot, key, label, text string) { - if policy == nil || strings.TrimSpace(text) == "" { - return - } - segment := cloudAgentContextSegment{Key: key, Label: label, Bytes: len(text), Tokens: estimateCloudAgentTokens([]byte(text))} - for index := range policy.SystemSegments { - if policy.SystemSegments[index].Key == key { - policy.SystemSegments[index] = segment - return - } - } - policy.SystemSegments = append(policy.SystemSegments, segment) } type cloudAgentProfileSnapshot struct { @@ -161,15 +115,11 @@ func compileCloudAgentPolicies(req CloudAgentRequest, skills []cloudAgentSkill, ProfileRevision: profile.Revision, ProfileHash: profile.Hash, } var b strings.Builder - var recorder cloudAgentSegmentRecorder b.WriteString(system.Text) b.WriteString("\n\n") - recorder.mark(&b, "policy", "系统行为策略") b.WriteString(media.Text) b.WriteString("\n\n") - recorder.mark(&b, "mediaPolicy", "媒体策略") b.WriteString(cloudAgentCapabilityGuide()) - recorder.mark(&b, "capabilities", "节点能力与选型") // Behavior belongs to versioned policies; the compiler only projects facts. context := map[string]any{ "source": "server_snapshot", "permissionMode": req.PermissionMode, @@ -207,8 +157,6 @@ func compileCloudAgentPolicies(req CloudAgentRequest, skills []cloudAgentSkill, } b.WriteString("\n\n本轮执行上下文:\n") b.Write(encoded) - recorder.mark(&b, "execution", "本轮执行上下文(事实快照)") - snapshot.SystemSegments = recorder.segments text := strings.TrimSpace(b.String()) if text == "" { return "", cloudAgentPolicySnapshot{}, fmt.Errorf("compiled Agent policy is empty") diff --git a/backend/internal/app/cloud_agent_prompt_context_test.go b/backend/internal/app/cloud_agent_prompt_context_test.go index c33fca10d..e30186952 100644 --- a/backend/internal/app/cloud_agent_prompt_context_test.go +++ b/backend/internal/app/cloud_agent_prompt_context_test.go @@ -25,9 +25,13 @@ func TestCloudAgentPromptContextKeepsUserTextAndFactBoundaries(t *testing.T) { if len(canonical.Messages) != before { t.Fatal("更新清单吞掉了输出恢复事件") } + responses, err := canonicalAgentResponsesBody(&canonical) + if err != nil { + t.Fatal(err) + } for _, body := range []map[string]any{ canonicalAgentChatBody(&canonical, false), - canonicalAgentResponsesBody(&canonical), + responses, canonicalAgentGeminiBody(&canonical), claudeAgentBody(canonicalAgentChatBody(&canonical, true)), } { diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 508ef018d..b23bde8b7 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -62,6 +62,7 @@ type CloudAgentEvent struct { type cloudAgentCall struct { ID string `json:"id"` + ItemID string `json:"item_id,omitempty"` Function struct { Name string `json:"name"` Arguments string `json:"arguments"` @@ -214,23 +215,6 @@ type cloudAgentRuntime struct { // 内存只保留最近一窗(repository.CloudAgentJournalWindow),因此它是"窗口在整条 // 日志里的偏移",而不是累计条数。只在内存里有效,不进检查点(检查点里的事件为空)。 EventSeqBase int `json:"-"` - // LastStep* 记下"最近一次已发出的模型调用"的本地计价,与上游回填的实测用量 - // 配成锚点用。估算与实测指向同一份 canonical:估算取自任务 input 里实际发出的那份, - // 因此"信封一致"是构造保证,不需要额外比对。 - LastStepTaskID string `json:"lastStepTaskId,omitempty"` - LastStepOperation string `json:"lastStepOperation,omitempty"` - LastStepEstimate int `json:"lastStepEstimate,omitempty"` - LastStepSourceBytes int `json:"lastStepSourceBytes,omitempty"` - LastStepSignature string `json:"lastStepSignature,omitempty"` - LastStepModel string `json:"lastStepModel,omitempty"` - LastStepChannelID string `json:"lastStepChannelId,omitempty"` - LastStepWindowTokens int `json:"lastStepWindowTokens,omitempty"` - // TokenAnchor 是上一步上游上报的用量(模型自己的分词器计数),上下文压力的权威锚点。 - TokenAnchor *cloudAgentTokenAnchor `json:"tokenAnchor,omitempty"` - // ContextWindowKnown 记录本轮是否已经看到过"模型窗口已确认"的读数:从"未确认"变为 - // "已确认"时要落一条 context_transition,消费方据此标"模型窗口已识别", - // 而不是把口径切换画成上下文骤降。 - ContextWindowKnown bool `json:"contextWindowKnown,omitempty"` } type cloudAgentTransientReference struct { @@ -284,37 +268,17 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent // 界面会一直停在上一轮(已结束)的清单上,显示"未完成项已停止"。 state.event(task.ID, "plan_updated", map[string]any{"items": state.Plan, "pendingTitles": cloudAgentPendingPlanItems(state.Plan), "inherited": true}) } - pressure := s.cloudAgentContextPressure(input.Requests.Canonical, initial.Request.Prompt, initial.Request) if carrier { // The carrier is deliberately not a model call. Pi will create the first // governed cloud_agent_step through the model bridge below. - state.ContextWindowKnown = pressure.ModelLimitConfigured 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 { return err } return s.repo.EnsureCloudAgent(run) } - // 第一步的模型调用就是根任务本身(不经过 enqueueCloudAgentTask):在这里登记任务 id - // 与本次请求的本地计价,它回来时才能与上游实测配成锚点。根任务的操作名是 - // cloud_agent,但它就是第一步的模型调用,按"步骤"口径登记,否则回来配锚点时会被 - // 操作名守卫挡掉。 - state.LastStepTaskID = task.ID - state.LastStepOperation = cloudAgentStepOperation - state.LastStepEstimate = pressure.EstimatedInputTokens - state.LastStepSourceBytes = pressure.SourceBytes - state.LastStepModel = stringValue(input.Config["model"]) - state.LastStepChannelID = stringValue(input.Config["channelId"]) - state.LastStepSignature = cloudAgentRequestSignature(&state, input.Requests.Canonical, state.LastStepChannelID, state.LastStepModel) - if pressure.ModelLimitConfigured { - state.LastStepWindowTokens = pressure.ContextWindowTokens - } - // 第一步的窗口是"起始状态"而不是"刚刚识别":只播种标记,不落 window_resolved, - // 否则每轮开头都会报一次"模型窗口已识别"。 - state.ContextWindowKnown = pressure.ModelLimitConfigured - firstPressure := cloudAgentContextPressurePayload(pressure, &state, input.Requests.Canonical) - firstPressure["requestId"] = task.ID - state.event(task.ID, "context_pressure", firstPressure) + // 非 Pi 的旧入口仍保留运行状态初始化,但上下文占用统一由 Pi SDK + // 在实际模型调用前上报;这里不再生成 Go 侧估算读数。 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 { return err diff --git a/backend/internal/app/cloud_agent_runtime_media.go b/backend/internal/app/cloud_agent_runtime_media.go index 31f5ade64..91faca4ab 100644 --- a/backend/internal/app/cloud_agent_runtime_media.go +++ b/backend/internal/app/cloud_agent_runtime_media.go @@ -115,21 +115,6 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c return err } task.InputJSON = string(raw) - // 压力读数只对"模型调用"这一步有意义:媒体任务发的是另一份请求(另一套信封), - // 把读数挂到那份信封上会误导消费方。 - var contextPressure *cloudAgentContextPressure - var requestCanonical canonicalAgentRequest - if media == nil { - if req.Operation != cloudAgentContextCompactionOperation { - canonical, ok := canonicalAgentRequestFromInput(input) - if !ok { - return fmt.Errorf("Agent 模型任务缺少可计量的请求信封") - } - requestCanonical = canonical - value := s.cloudAgentContextPressure(canonical, state.Request.Prompt, state.Request) - contextPressure = &value - } - } if prepare.Order != nil { task.BillingOrderID = prepare.Order.ID } @@ -202,25 +187,6 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c if state.ContextCompaction != nil && req.Operation == cloudAgentContextCompactionOperation { state.ContextCompaction.Status = "running" } else { - if contextPressure != nil { - config, _ := input["config"].(map[string]any) - model, channelID := stringValue(config["model"]), stringValue(config["channelId"]) - signature := cloudAgentRequestSignature(state, requestCanonical, channelID, model) - cloudAgentExpireTokenAnchorForRequest(run.ID, state, contextPressure.ContextWindowTokens, signature, model, channelID) - cloudAgentRecordMemorySegment(&state.Policy, requestCanonical.SystemPrompt) - payload := cloudAgentContextPressurePayload(*contextPressure, state, requestCanonical) - payload["requestId"] = task.ID - cloudAgentNoteContextWindowResolved(run.ID, state, *contextPressure) - state.event(run.ID, "context_pressure", payload) - state.LastStepTaskID = task.ID - state.LastStepOperation = req.Operation - state.LastStepEstimate = contextPressure.EstimatedInputTokens - state.LastStepSourceBytes = contextPressure.SourceBytes - state.LastStepSignature = signature - state.LastStepModel = model - state.LastStepChannelID = channelID - state.LastStepWindowTokens = contextPressure.ContextWindowTokens - } state.Step++ } } @@ -261,16 +227,15 @@ func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cl if submitted { state.MediaTaskID = "" } - // Only explicitly typed argument errors and a stale canvas snapshot may - // continue into another model turn. Every other pre-submission media - // failure is a real admission boundary failure; continuing would invite - // the model to submit unverified variants of a billed write. + // Only explicitly typed argument errors, stale canvas snapshots and known + // transient admission failures may continue into another model turn. A + // transient failure is safe here because no billable task was submitted. continueAfterAdmissionError := false if phase == "admission" && !submitted { var argumentErr *cloudAgentArgumentError continueAfterAdmissionError = errors.As(err, &argumentErr) var admissionErr *cloudAgentMediaAdmissionError - if errors.As(err, &admissionErr) && admissionErr.Reason == "snapshot_conflict" { + if errors.As(err, &admissionErr) && (admissionErr.Reason == "snapshot_conflict" || admissionErr.Retryable) { continueAfterAdmissionError = true } } diff --git a/backend/internal/app/cloud_agent_runtime_messages_test.go b/backend/internal/app/cloud_agent_runtime_messages_test.go index 3c3754878..90e3494f9 100644 --- a/backend/internal/app/cloud_agent_runtime_messages_test.go +++ b/backend/internal/app/cloud_agent_runtime_messages_test.go @@ -1,6 +1,11 @@ package app -import "testing" +import ( + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) // 运行时会话消息(user/assistant/toolResult + 内容块)必须转换成供应商层能接受的规范请求, // 否则第一步模型调用就会因“缺少工具选择”等校验失败。 @@ -10,12 +15,15 @@ func TestCanonicalFromRuntimeMessagesPassesProviderValidation(t *testing.T) { {"role": "assistant", "content": []interface{}{ map[string]interface{}{"type": "thinking", "thinking": "先看看画布"}, map[string]interface{}{"type": "text", "text": "我先读取画布。"}, - map[string]interface{}{"type": "toolCall", "id": "call-1", "name": "canvas_snapshot", "arguments": map[string]interface{}{}}, + map[string]interface{}{"type": "toolCall", "id": "call-1", "itemId": "fc-1", "name": "canvas_snapshot", "arguments": map[string]interface{}{}}, }}, {"role": "toolResult", "toolCallId": "call-1", "toolName": "canvas_snapshot", "content": []interface{}{map[string]interface{}{"type": "text", "text": "{\"nodes\":0}"}}, "isError": false}, } tools := runtimeToolSchemas([]map[string]any{{"name": "canvas_snapshot", "description": "获取画布快照", "parameters": map[string]any{"type": "object", "properties": map[string]any{}}}}) - canonical := canonicalFromRuntimeMessages(messages, tools, "你是画布助手") + canonical, err := canonicalFromRuntimeMessages(messages, tools, "你是画布助手") + if err != nil { + t.Fatal(err) + } if canonical.ToolChoice != "auto" { t.Fatalf("tool choice = %v", canonical.ToolChoice) } @@ -25,6 +33,10 @@ func TestCanonicalFromRuntimeMessagesPassesProviderValidation(t *testing.T) { if stringField(canonical.Messages[3], "role") != "tool" || stringField(canonical.Messages[3], "tool_call_id") != "call-1" { t.Fatalf("tool result not converted: %#v", canonical.Messages[3]) } + calls := canonicalAgentToolCalls(canonical.Messages[2]["tool_calls"]) + if len(calls) != 1 || stringField(calls[0], "item_id") != "fc-1" { + t.Fatalf("runtime Responses item id not preserved: %#v", calls) + } if _, err := expandCanonicalAgentRequest(&canonical, providerConfig{}, false); err != nil { t.Fatalf("provider validation rejected runtime transcript: %v", err) } @@ -41,3 +53,59 @@ func TestRuntimeToolCallsDecodeArguments(t *testing.T) { t.Fatalf("unexpected runtime call: %#v", out[0]) } } + +func TestValidateRuntimeMessagesForModelRejectsOrphanToolResult(t *testing.T) { + messages := []map[string]any{{"role": "user", "content": "继续"}, {"role": "toolResult", "toolCallId": "missing", "content": "{}"}} + if err := validateRuntimeMessagesForModel(messages); err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("orphan tool result error = %v", err) + } +} + +func TestValidateRuntimeMessagesForModelRejectsIncompleteToolTurn(t *testing.T) { + messages := []map[string]any{{"role": "user", "content": "读取"}, {"role": "assistant", "content": []interface{}{map[string]interface{}{"type": "toolCall", "id": "call-2", "name": "canvas_get_state", "arguments": map[string]interface{}{}}}}} + if err := validateRuntimeMessagesForModel(messages); err == nil || !strings.Contains(err.Error(), "call-2") { + t.Fatalf("incomplete tool turn error = %v", err) + } +} + +func TestValidateRuntimeMessagesForModelAllowsMultipleToolCalls(t *testing.T) { + messages := []map[string]any{ + {"role": "user", "content": "读取画布和当前选区"}, + {"role": "assistant", "content": []interface{}{ + map[string]interface{}{"type": "toolCall", "id": "call-state", "name": "canvas_get_state", "arguments": map[string]interface{}{}}, + map[string]interface{}{"type": "toolCall", "id": "call-selection", "name": "canvas_get_selection", "arguments": map[string]interface{}{}}, + }}, + {"role": "toolResult", "toolCallId": "call-selection", "content": "{}"}, + {"role": "toolResult", "toolCallId": "call-state", "content": "{}"}, + } + if err := validateRuntimeMessagesForModel(messages); err != nil { + t.Fatalf("multiple tool calls should be accepted: %v", err) + } +} + +func TestValidateCanonicalAgentHistoryRejectsOrphanToolResult(t *testing.T) { + request := canonicalAgentRequest{ + Messages: []map[string]interface{}{ + {"role": "user", "content": "继续"}, + {"role": "tool", "tool_call_id": "missing", "content": "{}"}, + }, + ToolChoice: "auto", + } + if _, err := expandCanonicalAgentRequest(&request, providerConfig{}, false); err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("orphan canonical tool result error = %v", err) + } +} + +func TestValidateCanonicalAgentHistoryRequiresResponsesItemID(t *testing.T) { + request := canonicalAgentRequest{ + Messages: []map[string]interface{}{ + {"role": "user", "content": "读取"}, + {"role": "assistant", "content": "", "tool_calls": []map[string]interface{}{{"id": "call-3", "function": map[string]interface{}{"name": "canvas_get_state", "arguments": "{}"}}}}, + {"role": "tool", "tool_call_id": "call-3", "content": "{}"}, + }, + ToolChoice: "auto", + } + if _, err := expandCanonicalAgentRequest(&request, providerConfig{InterfaceType: string(model.ChannelInterfaceOpenAIResponse)}, false); err == nil || !strings.Contains(err.Error(), "call-3") { + t.Fatalf("missing Responses item id error = %v", err) + } +} diff --git a/backend/internal/app/cloud_agent_runtime_scheduler.go b/backend/internal/app/cloud_agent_runtime_scheduler.go index 1ddb229b9..91d26b3e1 100644 --- a/backend/internal/app/cloud_agent_runtime_scheduler.go +++ b/backend/internal/app/cloud_agent_runtime_scheduler.go @@ -364,9 +364,6 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error) return s.failCloudAgent(run, &state, fmt.Sprintf("达到 %d 次模型调用上限,本轮已停止", stepLimit)) } cloudAgentDrainInterjections(run.ID, &state) - // 上一步的模型调用已经回来,先用它的上游实测用量更新压力锚点,再发下一步: - // 下一步的读数与后面的正文裁剪判定都要用到这份锚点。 - s.recordCloudAgentTokenAnchor(run.UserID, &state) // 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。 // 它必须在压缩判定之前跑:图片是最贵的一类内容,先移出再评估 token 压力才有意义。 if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, nil); changed { diff --git a/backend/internal/app/cloud_agent_runtime_tool_result_test.go b/backend/internal/app/cloud_agent_runtime_tool_result_test.go index 1f05bfae6..0a6f67047 100644 --- a/backend/internal/app/cloud_agent_runtime_tool_result_test.go +++ b/backend/internal/app/cloud_agent_runtime_tool_result_test.go @@ -2,10 +2,12 @@ package app import ( "context" + "fmt" "strings" "testing" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" ) // 线上复现:recall_lessons 经运行时工具桥执行后,交回模型的 tool 消息是 "null", @@ -19,16 +21,46 @@ func TestCloudAgentRuntimeToolResultIsNeverNull(t *testing.T) { if err != nil { t.Fatal(err) } - for _, tc := range []struct{ name, args string }{ + cases := []struct{ name, args string }{ {"recall_lessons", `{"topic":"用户称呼偏好"}`}, {"recall_lessons", `{}`}, {"canvas_list_node_types", `{}`}, {"canvas_get_state", `{}`}, - } { + } + calls := make([]cloudAgentCall, 0, len(cases)) + for index, tc := range cases { var call cloudAgentCall - call.ID = "call-" + tc.name + "-" + tc.args + call.ID = fmt.Sprintf("tool-result-%d", index) call.Function.Name = tc.name call.Function.Arguments = tc.args + calls = append(calls, call) + } + + // executeCloudAgentRuntimeTool is intentionally strict: a bridge result is + // accepted only when the model declaration has already been checkpointed. + // Build that same durable boundary here instead of bypassing the contract by + // calling the executor with an arbitrary call. + persisted, err := s.repo.CloudAgent("user", run.ID) + if err != nil { + t.Fatal(err) + } + state, err := cloudAgentDecode(persisted) + if err != nil { + t.Fatal(err) + } + state.Calls = calls + state.CallIndex = 0 + state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{ + "role": "assistant", "content": "", "tool_calls": calls, + }) + if err := s.repo.MutateCloudAgent("user", run.ID, persisted.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + return cloudAgentSave(current, &state) + }); err != nil { + t.Fatalf("persist model tool calls: %v", err) + } + + for index, tc := range cases { + call := calls[index] result, err := s.executeCloudAgentRuntimeTool(context.Background(), "user", run.ID, call) if err != nil { t.Fatalf("%s(%s) bridge error: %v", tc.name, tc.args, err) @@ -38,5 +70,16 @@ func TestCloudAgentRuntimeToolResultIsNeverNull(t *testing.T) { if strings.TrimSpace(content) == "" || content == "null" { t.Fatalf("%s(%s) returned empty tool content: %#v", tc.name, tc.args, result) } + + // The bridge can retry after losing its response. The second request must + // replay the committed tool message, not execute the next model call. + replayed, err := s.executeCloudAgentRuntimeTool(context.Background(), "user", run.ID, call) + if err != nil { + t.Fatalf("%s(%s) replay error: %v", tc.name, tc.args, err) + } + replayedPayload, _ := replayed.(map[string]any) + if replayedPayload["content"] != content { + t.Fatalf("%s(%s) replay changed committed content: first=%q replay=%#v", tc.name, tc.args, content, replayed) + } } } diff --git a/backend/internal/app/cloud_agent_runtime_tools.go b/backend/internal/app/cloud_agent_runtime_tools.go index 0a70c5bb0..01d2cac1c 100644 --- a/backend/internal/app/cloud_agent_runtime_tools.go +++ b/backend/internal/app/cloud_agent_runtime_tools.go @@ -138,11 +138,122 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen } state.event(runID, kind, payload) state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "tool", "tool_call_id": call.ID, "content": string(raw)}) + if err == nil { + cloudAgentCompactSupersededReadResult(state, call) + } state.CallIndex++ state.Approval = nil return exhausted } +// cloudAgentCompactSupersededReadResult bounds repeated snapshots without +// changing the transcript shape. A newer read with the same cache key makes an +// older canvas projection stale; keep the old tool message for provider pairing, +// but remove rereadable bodies while retaining hashes and execution facts. +func cloudAgentCompactSupersededReadResult(state *cloudAgentRuntime, current cloudAgentCall) { + if state == nil || !cloudAgentReadToolCacheable(current.Function.Name) { + return + } + if current.Function.Name != "canvas_get_state" && current.Function.Name != "canvas_read_storyboard" && current.Function.Name != "director_scene_read" { + return + } + currentKey := cloudAgentReadCacheKey(current) + protected := make(map[string]bool, len(state.Calls)) + for _, call := range state.Calls { + protected[call.ID] = true + } + callByID := make(map[string]cloudAgentCall) + for _, message := range state.Canonical.Messages { + if stringValue(message["role"]) != "assistant" { + continue + } + for _, rawCall := range canonicalAgentToolCalls(message["tool_calls"]) { + var call cloudAgentCall + encoded, err := json.Marshal(rawCall) + if err != nil || json.Unmarshal(encoded, &call) != nil || call.ID == "" { + continue + } + callByID[call.ID] = call + } + } + for _, message := range state.Canonical.Messages { + if stringValue(message["role"]) != "tool" { + continue + } + callID := stringValue(message["tool_call_id"]) + if callID == "" || callID == current.ID || protected[callID] { + continue + } + prior, ok := callByID[callID] + if !ok || prior.Function.Name != current.Function.Name || cloudAgentReadCacheKey(prior) != currentKey { + continue + } + if compacted := cloudAgentCompactSupersededReadBody(stringValue(message["content"]), current.Function.Name); compacted != "" { + message["content"] = compacted + } + } +} + +func cloudAgentCompactSupersededReadBody(content, toolName string) string { + var result map[string]any + if content == "" || json.Unmarshal([]byte(content), &result) != nil { + return "" + } + removed := false + switch toolName { + case "canvas_get_state": + if nodes, ok := result["nodes"].([]any); ok { + facts := make([]map[string]any, 0, len(nodes)) + for _, value := range nodes { + node, ok := value.(map[string]any) + if !ok { + continue + } + fact := map[string]any{"nodeId": node["id"]} + for _, key := range []string{"generation", "generationDraft", "outputReference"} { + if value, exists := node[key]; exists { + fact[key] = value + } + } + if len(fact) > 1 { + facts = append(facts, fact) + } + } + if len(facts) > 0 { + result["observedFacts"] = facts + } + delete(result, "nodes") + removed = true + } + if _, exists := result["connections"]; exists { + delete(result, "connections") + removed = true + } + case "canvas_read_storyboard": + if _, exists := result["rows"]; exists { + delete(result, "rows") + removed = true + } + case "director_scene_read": + for _, key := range []string{"content", "shots", "scenes"} { + if _, exists := result[key]; exists { + delete(result, key) + removed = true + } + } + } + if !removed { + return "" + } + result["contextCompacted"] = true + result["guidance"] = "较新的同参读取已替代这份旧快照;需要正文时重新读取,不要依据旧快照重复提交写入。" + encoded, err := json.Marshal(result) + if err != nil || len(encoded) >= len(content) { + return "" + } + return string(encoded) +} + // cloudAgentReadResultInContext reports whether the full cached result is still // present in the canonical transcript. Context compaction deliberately removes old // tool bodies, so a replay must restore the body once instead of returning a receipt diff --git a/backend/internal/app/cloud_agent_tool_error_class.go b/backend/internal/app/cloud_agent_tool_error_class.go index b16a445ab..0511ace8f 100644 --- a/backend/internal/app/cloud_agent_tool_error_class.go +++ b/backend/internal/app/cloud_agent_tool_error_class.go @@ -28,10 +28,9 @@ const ( // cloudAgentToolErrorClass 返回 (errorClass, retryable, requiredAction)。 // -// requiredAction 是给模型/界面看的"下一步该做什么",取稳定的短标识,不写自然语言: -// fix_arguments(按 schema 改参数后重试)、reread_canvas(重读画布再试)、 -// use_advertised_tools(只使用本轮工具表里列出的工具)、ask_user(权限被拒,别自己绕)、 -// report_to_user(上游故障,告诉用户)。 +// requiredAction 是给模型/界面看的"下一步该做什么",取稳定的短标识: +// fix_arguments(按 schema 改参数后重试)、reread_canvas(重读画布再试)、retry(临时故障自动重试)、 +// use_advertised_tools(只使用本轮工具表里列出的工具)、ask_user(权限被拒,别自己绕)、report_to_user(上游故障,告诉用户)。 func cloudAgentToolErrorClass(req CloudAgentRequest, call cloudAgentCall, err error, allowed bool) (string, bool, string) { if err == nil { return "", true, "" diff --git a/backend/internal/app/cloud_agent_tool_error_class_test.go b/backend/internal/app/cloud_agent_tool_error_class_test.go index 62e6d17d0..624a7adee 100644 --- a/backend/internal/app/cloud_agent_tool_error_class_test.go +++ b/backend/internal/app/cloud_agent_tool_error_class_test.go @@ -43,6 +43,13 @@ func TestCloudAgentToolErrorClass(t *testing.T) { ErrorClass: cloudAgentToolErrorAdmission, RequiredAction: "report_to_user", }, true, cloudAgentToolErrorAdmission, false, "report_to_user"}, + {"媒体准入临时故障", canvasRequest, call("generate_media"), &cloudAgentMediaAdmissionError{ + error: providerHTTPError{StatusCode: 503}, + Reason: "media_admission_failed", + ErrorClass: cloudAgentToolErrorAdmission, + Retryable: true, + RequiredAction: "retry", + }, true, cloudAgentToolErrorAdmission, true, "retry"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -134,3 +141,48 @@ func TestCloudAgentToolResultClassifiesMissingRequiredArguments(t *testing.T) { t.Fatalf("字段齐备却报缺字段:%v", missing) } } + +func TestCloudAgentMediaAdmissionRetryClassification(t *testing.T) { + retryableApp := NewAppError(503, "服务暂时不可用") + retryableApp.Retryable = true + cases := []struct { + name string + err error + wantRetry bool + wantAct string + }{ + {"retryable app error", retryableApp, true, "retry"}, + {"upstream 503", providerHTTPError{StatusCode: 503}, true, "retry"}, + {"upstream 400", providerHTTPError{StatusCode: 400}, false, "report_to_user"}, + {"timeout", context.DeadlineExceeded, true, "retry"}, + {"deterministic rejection", BadAuthRequest("模型不存在"), false, "report_to_user"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapped := cloudAgentWrapMediaAdmissionError(tc.err) + admission, ok := wrapped.(*cloudAgentMediaAdmissionError) + if !ok || admission.Retryable != tc.wantRetry || admission.RequiredAction != tc.wantAct { + t.Fatalf("wrapped admission = %#v, want retryable=%v action=%q", wrapped, tc.wantRetry, tc.wantAct) + } + }) + } +} + +func TestCloudAgentTransientMediaAdmissionUsesRepairBudget(t *testing.T) { + state := &cloudAgentRuntime{ToolRepairs: map[string]cloudAgentToolRepair{}} + call := cloudAgentCall{ID: "call-1"} + call.Function.Name = "generate_media" + err := &cloudAgentMediaAdmissionError{ + error: providerHTTPError{StatusCode: 503}, + Reason: "media_admission_failed", + Retryable: true, + } + for attempt := 1; attempt <= cloudAgentToolAttemptLimit; attempt++ { + result := map[string]any{"phase": "admission", "taskSubmitted": false} + payload := map[string]any{} + exhausted := cloudAgentTrackToolRepair("run-1", state, call, result, err, payload) + if exhausted != (attempt == cloudAgentToolAttemptLimit) { + t.Fatalf("attempt %d exhausted=%v", attempt, exhausted) + } + } +} diff --git a/backend/internal/app/cloud_agent_tool_repair.go b/backend/internal/app/cloud_agent_tool_repair.go index 7394724d5..6e7dbacbb 100644 --- a/backend/internal/app/cloud_agent_tool_repair.go +++ b/backend/internal/app/cloud_agent_tool_repair.go @@ -27,7 +27,8 @@ func cloudAgentRetryReceipt(groupID string, attempt int, status string) map[stri } // Count per tool, not per model step: intervening reads must not reset a failed -// write's allowance. Only explicitly typed, pre-execution errors are repairable. +// write's allowance. Only explicitly typed argument errors and safe, pre-submission +// transient admission errors are repairable. func cloudAgentTrackToolRepair(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error, payload map[string]any) bool { previous, exists := state.ToolRepairs[call.Function.Name] if err == nil { @@ -39,7 +40,10 @@ func cloudAgentTrackToolRepair(runID string, state *cloudAgentRuntime, call clou } detail, _ := result.(map[string]any) var argumentErr *cloudAgentArgumentError - if !errors.As(err, &argumentErr) || detail["taskSubmitted"] == true || detail["phase"] == "completion" { + argumentRepairable := errors.As(err, &argumentErr) + var admissionErr *cloudAgentMediaAdmissionError + admissionRepairable := errors.As(err, &admissionErr) && admissionErr.Retryable && detail["phase"] == "admission" && detail["taskSubmitted"] != true + if (!argumentRepairable && !admissionRepairable) || detail["taskSubmitted"] == true || detail["phase"] == "completion" { return false } if state.ToolRepairs == nil { diff --git a/backend/internal/app/cloud_agent_usage_anchor.go b/backend/internal/app/cloud_agent_usage_anchor.go deleted file mode 100644 index 161a2d19d..000000000 --- a/backend/internal/app/cloud_agent_usage_anchor.go +++ /dev/null @@ -1,159 +0,0 @@ -package app - -import ( - "encoding/json" - "strconv" - "strings" -) - -// cloudAgentTokenAnchor 是"上游实测 + 本地估算"的一对读数。 -// 上游用量来自模型自己的分词器(provider 上报),是压力读数可以采信的权威值; -// 本地估算记录的是发出同一次请求时的读法,二者的差就是投影后续请求所需的换算基准。 -type cloudAgentTokenAnchor struct { - TaskID string `json:"taskId"` - Step int `json:"step"` - InputTokens int64 `json:"inputTokens"` - CachedTokens int64 `json:"cachedTokens"` - OutputTokens int64 `json:"outputTokens"` - EstimatedTokens int `json:"estimatedTokens"` - SourceBytes int `json:"sourceBytes"` - Accepted bool `json:"accepted"` - RejectReason string `json:"rejectReason,omitempty"` - // Signature / Model / ChannelID 记录"定锚时的口径":换了模型、线路、系统提示或工具 schema - // 之后,旧实测不再可比,必须作废。 - Signature string `json:"signature,omitempty"` - Model string `json:"model,omitempty"` - ChannelID string `json:"channelId,omitempty"` - // ContextWindowTokens 是定锚时解析到的模型窗口:窗口是"离满窗还有多远"的分母, - // 换了窗口(管理员改了渠道模型能力、或换了路由落点)之后同一个 token 数含义就变了。 - ContextWindowTokens int `json:"contextWindowTokens,omitempty"` -} - -// cloudAgentAnchorMaxAgeSteps 是锚点的最长有效期:超过这么多步没有刷新就作废。 -// 真机实测过"锚点冻结"——估算从 42,581 涨到 66,105,压力却一直停在老读数上。 -const cloudAgentAnchorMaxAgeSteps = 3 - -// cloudAgentStepSignature 是"这一步的口径指纹":模型/渠道/逻辑模型 + 系统提示 + 工具 schema。 -// 它刻意不含消息正文:消息每步都在变,含进去等于每步作废,锚点就永远用不上。 -func cloudAgentStepSignature(state *cloudAgentRuntime) string { - if state == nil { - return "" - } - return cloudAgentRequestSignature(state, state.Canonical, "", "") -} - -func cloudAgentRequestSignature(state *cloudAgentRuntime, canonical canonicalAgentRequest, channelID, model string) string { - tools, _ := json.Marshal(canonical.Tools) - return creationHash(strings.Join([]string{ - state.Request.Model, state.Request.ChannelID, state.Request.ChannelModelKey, state.Request.LogicalModelID, - channelID, model, canonical.SystemPrompt, string(tools), - }, "\u0000")) -} - -// cloudAgentAnchorWindowTokens 把预算折算成锚点口径的窗口读数:没有解析到模型自己声明的 -// 窗口时报 0(未知),调用方据此跳过窗口比较,而不是拿兜底默认窗口当判据。 -func cloudAgentAnchorWindowTokens(budget cloudAgentContextBudget) int { - if !budget.Configured { - return 0 - } - return budget.ContextWindowTokens -} - -// cloudAgentExpireTokenAnchor 让"口径已变、窗口已变或太久没刷新"的锚点作废。 -// 只标记不删除:读数仍要能显示"这个实测是多少、为什么不再用它"。 -func cloudAgentExpireTokenAnchor(runID string, state *cloudAgentRuntime, windowTokens int) { - if state == nil { - return - } - cloudAgentExpireTokenAnchorForRequest(runID, state, windowTokens, cloudAgentStepSignature(state), state.Request.Model, state.Request.ChannelID) -} - -func cloudAgentExpireTokenAnchorForRequest(runID string, state *cloudAgentRuntime, windowTokens int, signature, model, channelID string) { - if state == nil || state.TokenAnchor == nil || !state.TokenAnchor.Accepted { - return - } - anchor := state.TokenAnchor - if anchor.Signature != "" && anchor.Signature != signature { - switch { - case anchor.Model != "" && anchor.Model != model: - anchor.RejectReason = "模型已变化,锚点作废" - state.event(runID, "context_transition", map[string]any{"kind": "model_changed", "reason": "anchor_signature_changed", "text": anchor.RejectReason}) - case anchor.ChannelID != "" && anchor.ChannelID != channelID: - anchor.RejectReason = "供应线路已变化,锚点作废" - state.event(runID, "context_transition", map[string]any{"kind": "route_changed", "reason": "anchor_signature_changed", "text": anchor.RejectReason}) - default: - anchor.RejectReason = "系统提示或工具 schema 已变化,锚点作废" - } - anchor.Accepted = false - return - } - if windowTokens > 0 && anchor.ContextWindowTokens > 0 && windowTokens != anchor.ContextWindowTokens { - anchor.RejectReason = "模型窗口已变化,锚点作废" - anchor.Accepted = false - state.event(runID, "context_transition", map[string]any{ - "kind": "window_changed", "reason": "anchor_window_changed", - "before": map[string]any{"contextWindowTokens": anchor.ContextWindowTokens}, - "after": map[string]any{"contextWindowTokens": windowTokens}, - "text": anchor.RejectReason, - }) - return - } - if state.Step-anchor.Step > cloudAgentAnchorMaxAgeSteps { - anchor.RejectReason = "锚点超过 " + strconv.Itoa(cloudAgentAnchorMaxAgeSteps) + " 步未刷新,已作废" - anchor.Accepted = false - } -} - -// cloudAgentAnchorMinRatio / MaxRatio 是采信上游用量的合理区间。 -// 实测与自估差出一个量级时通常意味着换了模型或计量口径(例如上游只报缓存命中、 -// 或走了不同的协议分支),此时宁可继续用估算,也不要把压力曲线锚到错误基准上。 -const ( - cloudAgentAnchorMinRatio = 0.5 - cloudAgentAnchorMaxRatio = 2.0 -) - -// recordCloudAgentTokenAnchor 用上一步的上游实测用量给上下文压力定锚。 -// 幂等:同一任务只采信一次;没有实测或比值离谱时记录拒绝原因并保留估算。 -// 当前请求的口径变化要等到实际任务输入确定后再判断,不能拿运行态副本替代请求信封。 -func (s *Service) recordCloudAgentTokenAnchor(userID string, state *cloudAgentRuntime) { - if s == nil || state == nil { - return - } - if s.repo == nil || state.LastStepTaskID == "" || state.LastStepEstimate <= 0 || state.LastStepSignature == "" { - return - } - // 只有"模型调用"这一步能配锚点:媒体任务发的是另一份请求(另一套信封), - // 拿它当锚点会把压力算到错误的信封上。 - if state.LastStepOperation != cloudAgentStepOperation { - return - } - if state.TokenAnchor != nil && state.TokenAnchor.TaskID == state.LastStepTaskID { - return - } - log, ok, err := s.repo.APICallLogUsageForTask(userID, state.LastStepTaskID) - if err != nil || !ok { - return - } - // A routed model may select another channel after the request was assembled. - // Do not project that provider's tokenizer onto a different known route. - if log.ChannelID != "" && state.LastStepChannelID != "" && log.ChannelID != state.LastStepChannelID { - return - } - anchor := &cloudAgentTokenAnchor{ - TaskID: state.LastStepTaskID, Step: state.Step, InputTokens: log.InputTokens, - CachedTokens: log.CachedTokens, OutputTokens: log.OutputTokens, - EstimatedTokens: state.LastStepEstimate, SourceBytes: state.LastStepSourceBytes, - Signature: state.LastStepSignature, Model: state.LastStepModel, ChannelID: state.LastStepChannelID, - ContextWindowTokens: state.LastStepWindowTokens, - } - ratio := float64(anchor.InputTokens) / float64(anchor.EstimatedTokens) - switch { - case ratio < cloudAgentAnchorMinRatio: - anchor.RejectReason = "上游实测远低于本地估算,可能换了模型或口径" - case ratio > cloudAgentAnchorMaxRatio: - anchor.RejectReason = "上游实测远高于本地估算,可能换了模型或口径" - default: - anchor.Accepted = true - } - state.TokenAnchor = anchor -} diff --git a/backend/internal/app/provider_agent_protocol.go b/backend/internal/app/provider_agent_protocol.go index 5a5e7a757..c53c00002 100644 --- a/backend/internal/app/provider_agent_protocol.go +++ b/backend/internal/app/provider_agent_protocol.go @@ -3,6 +3,7 @@ package app import ( "encoding/json" "errors" + "fmt" "strings" "infinite-canvas/backend/internal/model" @@ -24,9 +25,17 @@ func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerC } for _, message := range source.Messages { if stringField(message, "type") == "function_call" { - if stringField(message, "call_id") == "" || stringField(message, "name") == "" { + callID, err := canonicalAgentFunctionCallID(message, "画布 Agent 工具调用") + if err != nil { + return nil, err + } + if callID == "" || stringField(message, "name") == "" { return nil, errors.New("画布 Agent 工具调用缺少标识或名称") } + message["call_id"] = callID + if _, err := canonicalAgentFunctionCallItemID(message); err != nil { + return nil, err + } if _, ok := message["arguments"].(string); !ok { return nil, errors.New("画布 Agent 工具调用参数无效") } @@ -57,6 +66,9 @@ func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerC return nil, errors.New("画布 Agent 工具定义缺少名称") } } + if err := validateCanonicalAgentHistory(source, config.InterfaceType == string(model.ChannelInterfaceOpenAIResponse)); err != nil { + return nil, err + } switch choice := source.ToolChoice.(type) { case string: if choice != "auto" && choice != "required" { @@ -89,7 +101,11 @@ func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerC result := &agentToolRequests{} if declarative { result.ChatCompletion = canonicalAgentChatBody(&request, false) - result.Responses = canonicalAgentResponsesBody(&request) + responses, err := canonicalAgentResponsesBody(&request) + if err != nil { + return nil, err + } + result.Responses = responses result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true)) result.Claude["model"] = config.Model result.Gemini = canonicalAgentGeminiBody(&request) @@ -97,7 +113,11 @@ func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerC } switch config.InterfaceType { case string(model.ChannelInterfaceOpenAIResponse): - result.Responses = canonicalAgentResponsesBody(&request) + responses, err := canonicalAgentResponsesBody(&request) + if err != nil { + return nil, err + } + result.Responses = responses case string(model.ChannelInterfaceClaudeAPI): result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true)) default: @@ -165,21 +185,60 @@ func canonicalAgentChatBody(source *canonicalAgentRequest, claude bool) map[stri return body } -func canonicalAgentResponsesBody(source *canonicalAgentRequest) map[string]interface{} { +func canonicalAgentResponsesBody(source *canonicalAgentRequest) (map[string]interface{}, error) { messages := make([]interface{}, 0, len(source.Messages)) for _, message := range source.Messages { switch { case stringField(message, "type") == "function_call": - messages = append(messages, map[string]interface{}{ - "type": "function_call", "call_id": message["call_id"], "name": message["name"], "arguments": message["arguments"], - }) + callID, err := canonicalAgentFunctionCallID(message, "画布 Agent 工具调用") + if err != nil { + return nil, err + } + if callID == "" { + return nil, errors.New("画布 Agent 工具调用缺少 call_id") + } + itemID, err := canonicalAgentFunctionCallItemID(message) + if err != nil { + return nil, err + } + item := map[string]interface{}{ + "type": "function_call", "call_id": callID, "name": message["name"], "arguments": message["arguments"], + } + if itemID != "" { + item["id"] = itemID + item["item_reference"] = itemID + } + messages = append(messages, item) case stringField(message, "role") == "tool": - messages = append(messages, map[string]interface{}{"type": "function_call_output", "call_id": message["tool_call_id"], "output": message["content"]}) + callID, err := canonicalAgentStringAliases(message, "画布 Agent 工具结果 call_id", "tool_call_id", "toolCallId", "call_id") + if err != nil { + return nil, err + } + if callID == "" { + return nil, errors.New("画布 Agent 工具结果缺少 tool_call_id") + } + messages = append(messages, map[string]interface{}{"type": "function_call_output", "call_id": callID, "output": message["content"]}) default: messages = append(messages, map[string]interface{}{"role": message["role"], "content": canonicalAgentContent(message["content"], "responses")}) for _, call := range canonicalAgentToolCalls(message["tool_calls"]) { function, _ := call["function"].(map[string]interface{}) - messages = append(messages, map[string]interface{}{"type": "function_call", "call_id": call["id"], "name": function["name"], "arguments": function["arguments"]}) + callID, err := canonicalAgentToolCallID(call, "画布 Agent 工具调用") + if err != nil { + return nil, err + } + if callID == "" || stringField(function, "name") == "" { + return nil, errors.New("画布 Agent 工具调用缺少标识或名称") + } + itemID, err := canonicalAgentToolCallItemID(call) + if err != nil { + return nil, err + } + item := map[string]interface{}{"type": "function_call", "call_id": callID, "name": function["name"], "arguments": function["arguments"]} + if itemID != "" { + item["id"] = itemID + item["item_reference"] = itemID + } + messages = append(messages, item) } } } @@ -194,7 +253,143 @@ func canonicalAgentResponsesBody(source *canonicalAgentRequest) map[string]inter if source.PromptCacheKey != "" { body["prompt_cache_key"] = source.PromptCacheKey } - return body + return body, nil +} + +func canonicalAgentStringAliases(fields map[string]interface{}, label string, aliases ...string) (string, error) { + value := "" + firstAlias := "" + for _, alias := range aliases { + raw, present := fields[alias] + if !present || raw == nil { + continue + } + text, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s 字段 %q 必须是字符串", label, alias) + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + if value != "" && value != text { + return "", fmt.Errorf("%s 的别名 %q 与 %q 冲突", label, firstAlias, alias) + } + if value == "" { + value, firstAlias = text, alias + } + } + return value, nil +} + +func canonicalAgentFunctionCallID(fields map[string]interface{}, label string) (string, error) { + return canonicalAgentStringAliases(fields, label+" call_id", "call_id", "toolCallId", "tool_call_id") +} + +func canonicalAgentToolCallID(fields map[string]interface{}, label string) (string, error) { + return canonicalAgentStringAliases(fields, label+" call_id", "id", "call_id", "toolCallId", "tool_call_id") +} + +func canonicalAgentFunctionCallItemID(message map[string]interface{}) (string, error) { + return canonicalAgentStringAliases(message, "画布 Agent function_call item id", "item_id", "itemId", "id") +} + +func canonicalAgentToolCallItemID(call map[string]interface{}) (string, error) { + return canonicalAgentStringAliases(call, "画布 Agent tool call item id", "item_id", "itemId") +} + +func validateCanonicalAgentHistory(source *canonicalAgentRequest, requireResponseItemIDs bool) error { + if source == nil { + return errors.New("画布 Agent 会话为空") + } + pending := make(map[string]string) + for _, message := range source.Messages { + if stringField(message, "type") == "function_call" { + callID, err := canonicalAgentFunctionCallID(message, "画布 Agent 工具调用") + if err != nil { + return err + } + if callID == "" { + return errors.New("画布 Agent 工具调用缺少 call_id") + } + if _, exists := pending[callID]; exists { + return fmt.Errorf("画布 Agent 工具调用 %q 重复或缺少结果", callID) + } + itemID, err := canonicalAgentFunctionCallItemID(message) + if err != nil { + return err + } + pending[callID] = itemID + continue + } + + role := stringField(message, "role") + switch role { + case "assistant": + calls := canonicalAgentToolCalls(message["tool_calls"]) + if len(calls) == 0 { + if callID := firstPendingCanonicalAgentCall(pending); callID != "" { + return fmt.Errorf("画布 Agent 工具调用 %q 缺少结果", callID) + } + continue + } + if callID := firstPendingCanonicalAgentCall(pending); callID != "" { + return fmt.Errorf("画布 Agent 工具调用 %q 缺少结果", callID) + } + for _, call := range calls { + callID, err := canonicalAgentToolCallID(call, "画布 Agent 工具调用") + if err != nil { + return err + } + if callID == "" { + return errors.New("画布 Agent 工具调用缺少 call_id") + } + if _, exists := pending[callID]; exists { + return fmt.Errorf("画布 Agent 工具调用 %q 重复或缺少结果", callID) + } + itemID, err := canonicalAgentToolCallItemID(call) + if err != nil { + return err + } + pending[callID] = itemID + } + case "tool": + callID, err := canonicalAgentStringAliases(message, "画布 Agent 工具结果 call_id", "tool_call_id", "toolCallId", "call_id") + if err != nil { + return err + } + if callID == "" { + return errors.New("画布 Agent 工具结果缺少 tool_call_id") + } + itemID, exists := pending[callID] + if !exists { + return fmt.Errorf("画布 Agent 工具结果 %q 没有对应的工具调用", callID) + } + if requireResponseItemIDs && itemID == "" { + return fmt.Errorf("画布 Agent Responses 工具调用 %q 缺少 function_call item id", callID) + } + delete(pending, callID) + case "system", "user": + if callID := firstPendingCanonicalAgentCall(pending); callID != "" { + return fmt.Errorf("画布 Agent 工具调用 %q 缺少结果", callID) + } + case "": + return errors.New("画布 Agent 会话条目缺少角色") + default: + return fmt.Errorf("画布 Agent 会话角色无效: %s", role) + } + } + if callID := firstPendingCanonicalAgentCall(pending); callID != "" { + return fmt.Errorf("画布 Agent 工具调用 %q 缺少结果", callID) + } + return nil +} + +func firstPendingCanonicalAgentCall(pending map[string]string) string { + for callID := range pending { + return callID + } + return "" } func canonicalAgentGeminiBody(source *canonicalAgentRequest) map[string]interface{} { diff --git a/backend/internal/app/provider_test.go b/backend/internal/app/provider_test.go index 576daf0da..12b744b5c 100644 --- a/backend/internal/app/provider_test.go +++ b/backend/internal/app/provider_test.go @@ -399,7 +399,7 @@ func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { "output": []interface{}{ map[string]interface{}{"type": "reasoning", "summary": []interface{}{map[string]interface{}{"type": "summary_text", "text": "先读取画布,再决定操作"}}}, map[string]interface{}{"type": "message", "content": []interface{}{map[string]interface{}{"type": "output_text", "text": "开始操作"}}}, - map[string]interface{}{"type": "function_call", "call_id": "call-2", "name": "canvas_apply_ops", "arguments": `{"ops":[]}`}, + map[string]interface{}{"type": "function_call", "id": "fc-2", "call_id": "call-2", "name": "canvas_apply_ops", "arguments": `{"ops":[]}`}, }, }, "responses") if err != nil { @@ -417,7 +417,7 @@ func TestParseAgentToolPayloadSupportsResponses(t *testing.T) { } call, _ := calls[0].(map[string]interface{}) function, _ := call["function"].(map[string]interface{}) - if call["id"] != "call-2" || function["name"] != "canvas_apply_ops" || function["arguments"] != `{"ops":[]}` { + if call["id"] != "call-2" || call["item_id"] != "fc-2" || function["name"] != "canvas_apply_ops" || function["arguments"] != `{"ops":[]}` { t.Fatalf("tool call = %#v", call) } } @@ -491,7 +491,8 @@ func TestCanonicalAgentBodiesPreserveAssistantToolCalls(t *testing.T) { Messages: []map[string]any{ {"role": "user", "content": "读取画布"}, {"role": "assistant", "content": "我先查看当前内容。", "tool_calls": []cloudAgentCall{{ - ID: "call-5", + ID: "call-5", + ItemID: "fc-5", Function: struct { Name string `json:"name"` Arguments string `json:"arguments"` @@ -532,13 +533,16 @@ func TestCanonicalAgentBodiesPreserveAssistantToolCalls(t *testing.T) { t.Fatalf("claude assistant lost tool use: %#v", claudeAssistant) } - responses := canonicalAgentResponsesBody(&request) + responses, err := canonicalAgentResponsesBody(&request) + if err != nil { + t.Fatal(err) + } responseInput, _ := responses["input"].([]interface{}) if len(responseInput) != 4 { t.Fatalf("responses input = %#v", responseInput) } functionCall, _ := responseInput[2].(map[string]interface{}) - if functionCall["type"] != "function_call" || functionCall["name"] != "canvas_get_state" { + if functionCall["type"] != "function_call" || functionCall["name"] != "canvas_get_state" || functionCall["id"] != "fc-5" || functionCall["item_reference"] != "fc-5" { t.Fatalf("responses function call = %#v", functionCall) } } @@ -782,6 +786,30 @@ data: [DONE] } } +func TestStreamingAgentParserPreservesResponsesFunctionCallItemID(t *testing.T) { + parser := newStreamingAgentParser("responses", nil) + parser.consume("text/event-stream", []byte(`event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc-stream","call_id":"call-stream","name":"canvas_get_state","arguments":""}} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","item_id":"fc-stream","delta":"{\"nodeId\":"} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","item_id":"fc-stream","arguments":"{\"nodeId\":\"n1\"}"} + +`)) + parser.flush() + result, err := parser.result() + if err != nil { + t.Fatalf("streamingAgentParser.result() error = %v", err) + } + calls, _ := result["toolCalls"].([]interface{}) + call, _ := calls[0].(map[string]interface{}) + if call["id"] != "call-stream" || call["item_id"] != "fc-stream" { + t.Fatalf("responses tool call identity = %#v", call) + } +} + func TestStreamingAgentParserSeparatesResponsesReasoningFromVisibleText(t *testing.T) { var deltas strings.Builder parser := newStreamingAgentParser("responses", func(delta string) { diff --git a/backend/internal/app/provider_text_agent.go b/backend/internal/app/provider_text_agent.go index 060164208..dc8eedb0a 100644 --- a/backend/internal/app/provider_text_agent.go +++ b/backend/internal/app/provider_text_agent.go @@ -402,7 +402,12 @@ func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map if stringField(item, "type") != "function_call" { continue } - calls = append(calls, map[string]interface{}{"id": firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}}) + callID := firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")) + call := map[string]interface{}{"id": callID, "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}} + if itemID := stringField(item, "id"); itemID != "" && stringField(item, "call_id") != "" { + call["item_id"] = itemID + } + calls = append(calls, call) } result["toolCalls"] = calls return result, nil diff --git a/backend/internal/app/provider_text_agent_stream.go b/backend/internal/app/provider_text_agent_stream.go index e2c2cbf93..a969fa0fb 100644 --- a/backend/internal/app/provider_text_agent_stream.go +++ b/backend/internal/app/provider_text_agent_stream.go @@ -42,6 +42,7 @@ func postStreamingAgent(ctx context.Context, config providerConfig, path string, type streamingAgentToolCall struct { id string + itemID string name string arguments string } @@ -140,7 +141,7 @@ func (p *streamingAgentParser) consumeResponsesEvent(eventName string, payload m item, _ := payload["item"].(map[string]interface{}) if stringField(item, "type") == "function_call" { index := intField(payload, "output_index", len(p.toolCalls)) - p.setToolCall(index, firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), stringField(item, "name"), stringField(item, "arguments")) + p.setToolCall(index, firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), stringField(item, "id"), stringField(item, "name"), stringField(item, "arguments")) } case "response.function_call_arguments.delta": index := p.responseToolCallIndex(payload) @@ -202,7 +203,7 @@ func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{} arguments = string(encoded) } } - p.setToolCall(index, stringField(block, "id"), stringField(block, "name"), arguments) + p.setToolCall(index, stringField(block, "id"), "", stringField(block, "name"), arguments) } case "content_block_delta": delta, _ := payload["delta"].(map[string]interface{}) @@ -256,12 +257,15 @@ func (p *streamingAgentParser) toolCall(index int) *streamingAgentToolCall { return p.toolCalls[index] } -func (p *streamingAgentParser) setToolCall(index int, id string, name string, arguments string) { +func (p *streamingAgentParser) setToolCall(index int, id string, itemID string, name string, arguments string) { call := p.toolCall(index) - call.id, call.name, call.arguments = id, name, arguments + call.id, call.itemID, call.name, call.arguments = id, itemID, name, arguments if id != "" { p.toolCallByID[id] = index } + if itemID != "" { + p.toolCallByID[itemID] = index + } } func (p *streamingAgentParser) result() (map[string]interface{}, error) { @@ -304,7 +308,11 @@ func (p *streamingAgentParser) result() (map[string]interface{}, error) { if err := json.Unmarshal([]byte(arguments), &parsed); err != nil { return nil, fmt.Errorf("Agent 工具参数不是完整 JSON:%w", err) } - calls = append(calls, map[string]interface{}{"id": call.id, "type": "function", "function": map[string]interface{}{"name": call.name, "arguments": arguments}}) + mapped := map[string]interface{}{"id": call.id, "type": "function", "function": map[string]interface{}{"name": call.name, "arguments": arguments}} + if call.itemID != "" { + mapped["item_id"] = call.itemID + } + calls = append(calls, mapped) } result["toolCalls"] = calls if p.text.Len() == 0 && len(calls) == 0 { diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index c6d7de176..8f1f06f75 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -229,15 +229,21 @@ func (r *Repository) APICallLogUsageForTask(userID, taskID string) (model.ApiCal return log, true, nil } +// LatestAPICallForTask 返回任务最近一次非轮询上游调用的失败摘要。 +func (r *Repository) LatestAPICallForTask(taskID string) (model.ApiCallLog, error) { + if strings.TrimSpace(taskID) == "" { + return model.ApiCallLog{}, nil + } + var log model.ApiCallLog + err := r.db.Select("status,status_code,error_code,error").Where("task_id = ? AND request_kind <> ?", taskID, "poll"). + Order("created_at DESC").Limit(1).Find(&log).Error + return log, err +} + // LatestAPICallStatusForTask 返回任务最近一次上游调用的 HTTP 状态码; // 任务没收到任何上游响应(例如网络错误)时返回 0。 func (r *Repository) LatestAPICallStatusForTask(taskID string) (int, error) { - if strings.TrimSpace(taskID) == "" { - return 0, nil - } - var log model.ApiCallLog - err := r.db.Select("status_code").Where("task_id = ? AND request_kind <> ?", taskID, "poll"). - Order("created_at DESC").Limit(1).Find(&log).Error + log, err := r.LatestAPICallForTask(taskID) return log.StatusCode, err }