feat(agent): 云端 Agent 运行时 - 完善 Pi 桥接、上下文压力与临时故障重试

This commit is contained in:
ddcat666
2026-10-04 02:20:30 +08:00
parent 0030f4da9b
commit 76ca0fd247
32 changed files with 1663 additions and 1485 deletions

View File

@@ -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;
},
);
}
}
});

View File

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

View File

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

View File

@@ -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": "模型窗口已识别:读数改用窗口口径,这不是上下文变少",
})
}

View File

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

View File

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

View File

@@ -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)),
} {

View File

@@ -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("当前运行状态无效")

View File

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

View File

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

View File

@@ -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<summary>\n"
const cloudAgentPiCompactionSummaryMessageSuffix = "\n</summary>"
// 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())
}

View File

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

View File

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

View File

@@ -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": "<conversation>Earlier context</conversation>"}}},
}
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)
}
})
}
}

View File

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

View File

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

View File

@@ -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)),
} {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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