feat(agent): 云端 Agent 运行时 - 完善 Pi 桥接、上下文压力与临时故障重试
This commit is contained in:
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "模型窗口已识别:读数改用窗口口径,这不是上下文变少",
|
||||
})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
} {
|
||||
|
||||
@@ -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("当前运行状态无效")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
277
backend/internal/app/cloud_agent_pi_model_test.go
Normal file
277
backend/internal/app/cloud_agent_pi_model_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)),
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{} {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user