Merge remote-tracking branch 'origin/pr-587' into codex/pr-557-589-security-integration

# Conflicts:
#	backend/internal/app/cloud_agent_runtime.go
This commit is contained in:
ddCat
2026-09-22 11:19:54 +08:00
11 changed files with 1322 additions and 7 deletions

View File

@@ -94,6 +94,18 @@ type cloudAgentRuntime struct {
// StepLimits 是本步实际生效的执行边界(管理员策略解析结果),只用于构造请求:
// 不进状态 JSON——每次推进都按当时的策略重新解析,改配置无需重发本轮。
StepLimits cloudAgentStepLimits `json:"-"`
// ImageInspectCounts 记录本轮内每张图被查看的次数,用于"同一张图不要反复看"的护栏。
ImageInspectCounts map[string]int `json:"imageInspectCounts,omitempty"`
// PendingVisualNodeID 是刚看过、还没写观察的那张图;模型下一次输出正文时把观察记到锚点。
PendingVisualNodeID string `json:"pendingVisualNodeId,omitempty"`
// PendingImageInspections 暂存"本批还有工具结果没入历史"的看图结果,等整批 tool
// 结果都入历史后合并成一条 user 图片消息(见 cloudAgentFlushPendingImages)。
//
// 它必须进检查点,不能标 `json:"-"`:一次 advanceCloudAgent 只执行一个工具调用
// (advanceCloudAgentTool 执行完就 return,下一批调用走下一次转移),而每次转移都
// 从 StateJSON 重新解码(cloudAgentDecode)。进程内字段在下一个调用到来时必然为空,
// 缓冲就白缓冲了。载荷只有回执与签名链接,几十字节级。
PendingImageInspections []cloudAgentImageInspection `json:"pendingImageInspections,omitempty"`
}
type cloudAgentTransientReference struct {
@@ -414,6 +426,14 @@ func cloudAgentSave(run *model.CloudAgentExecution, state *cloudAgentRuntime) er
if run == nil || state == nil {
return errors.New("Agent runtime state is missing")
}
// 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。
// 上游每一步都会重新读取历史里的图片并按视觉 token 计费,保留整段历史既贵又没有新信息。
if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, state.cloudAgentVisualNotes()); changed && run.ID != "" {
state.event(run.ID, "context_images_pruned", map[string]any{
"prunedImages": pruned, "retentionRounds": cloudAgentImageRetentionRounds,
"text": "已把超出保留轮次的看图结果移出模型上下文(保留文字回执与 nodeId)",
})
}
if run.ID != "" {
if err := validateCloudAgentRuntime(run, state); err != nil {
return fmt.Errorf("%w: %v", errCloudAgentCheckpoint, err)
@@ -694,6 +714,9 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
}
if result.Text != "" {
state.event(run.ID, "assistant_message", map[string]any{"messageId": task.ID, "text": result.Text})
// 刚看过图时,模型在正文里写下的那句话就是可复用的视觉证据:
// 推理内容不回灌上下文,只有正文留得下来。
state.recordCloudAgentVisualNote(result.Text)
if len(calls) == 0 {
state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": result.Text})
}
@@ -731,6 +754,11 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
if state.CallIndex < len(state.Calls) {
return s.advanceCloudAgentTool(run, &state)
}
// 兜底 flush:本批调用都执行完了(无论最后一个调用是不是看图、有没有被中断),
// 缓冲里的图片必须在这里合并成一条 user 消息落到全部 tool 结果之后。少了这一步,
// "看图不是最后一个调用"的批次会把图片永久丢掉,而对应的 tool 回执已经在历史里。
// 幂等:正常路径(最后一个调用就是看图)已经在 cloudAgentRecordToolResult 里 flush 过。
cloudAgentFlushPendingImages(&state)
contextBudget := s.cloudAgentContextBudgetForRequest(state.Request)
if compactCloudAgentContext(&state.Canonical, contextBudget) {
// Evicted read bodies must be obtainable again after compaction.
@@ -741,6 +769,14 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error)
return s.failCloudAgent(run, &state, fmt.Sprintf("达到 %d 次模型调用上限,本轮已停止", stepLimit))
}
cloudAgentDrainInterjections(run.ID, &state)
// 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。
// 它必须在压缩判定之前跑:图片是最贵的一类内容,先移出再评估 token 压力才有意义。
if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, state.cloudAgentVisualNotes()); changed {
state.event(run.ID, "context_images_pruned", map[string]any{
"prunedImages": pruned, "retentionRounds": cloudAgentImageRetentionRounds,
"text": "图片裁剪:移出超出保留轮次的看图结果",
})
}
canonical, contextErr := s.cloudAgentModelContext(run, &state, contextBudget)
if contextErr != nil {
var appErr *AppError
@@ -1042,6 +1078,30 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen
} else {
payload["text"] = "工具执行成功"
}
if inspection, ok := result.(cloudAgentImageInspection); ok && err == nil {
receipt, _ := json.Marshal(inspection.Receipt)
payload["result"] = inspection.Receipt
state.event(runID, kind, payload)
// tool 角色只接受字符串内容(四种上游图式都是纯文本),因此工具回执照常入历史,
// 图片另起一条 user 消息携带,并显式标注为数据而非指令。
state.Canonical.Messages = append(state.Canonical.Messages,
map[string]any{"role": "tool", "tool_call_id": call.ID, "content": string(receipt)})
// 重复查看时只回执文字(ImageURL 为空),不再附图。
if strings.TrimSpace(inspection.ImageURL) != "" {
cloudAgentStageImageInspection(state, inspection)
}
// 一批里可能有多个调用(模型一次发起 parallel tool calls),上游要求
// assistant(tool_calls) 之后紧跟每一个 tool_call_id 的 tool 消息,所以图片
// 不能插在 tool 结果之间。只有本批最后一个调用执行完,才把整批缓冲合并成
// 一条 user 消息追加在全部 tool 结果之后。
if state.CallIndex+1 >= len(state.Calls) {
cloudAgentFlushPendingImages(state)
}
state.CallIndex++
state.Approval = nil
// 看图是只读成功路径,不占自动纠错名额(那名额只给写/生成类工具的预执行参数错误)。
return false
}
exhausted := cloudAgentTrackToolRepair(runID, state, call, result, err, payload)
raw, _ := json.Marshal(result)
payload["result"] = result
@@ -1215,6 +1275,12 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
modelList, modelListErr = s.cloudAgentModelList(intent)
}
}
// 看图需要读取资源并签发链接,放在事务外完成,避免把网络/文件 IO 塞进写事务。
var inspectionResult any
var inspectionErr error
if allowed && call.Function.Name == "canvas_inspect_image" && state.Request.VisionEnabled {
inspectionResult, inspectionErr = s.prepareCloudAgentImageInspection(run.UserID, state.Request.CanvasID, state, call)
}
// Skill reads use the domain repository and filesystem, not the checkpoint
// transaction's connection. Read first to avoid nesting DB reads on SQLite.
var skillResult any
@@ -1245,6 +1311,13 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
result, toolErr = applyCloudAgentBatchTableMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
case call.Function.Name == "model_list":
result, toolErr = modelList, modelListErr
case call.Function.Name == "canvas_inspect_image":
result, toolErr = inspectionResult, inspectionErr
if toolErr == nil && inspectionResult != nil {
if inspection, ok := inspectionResult.(cloudAgentImageInspection); ok {
state.markCanvasAssetInspected(stringValue(inspection.Receipt["nodeId"]))
}
}
case call.Function.Name == "skill_read_file", call.Function.Name == "image_annotation_render":
result, toolErr = skillResult, skillErr
default: