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
}