756 lines
32 KiB
Go
756 lines
32 KiB
Go
// 画布 Agent 工具调用的执行与结果回填。
|
||
//
|
||
// 只读工具可以在同一步内批量执行(advanceCloudAgentReadBatch),结果按参数哈希缓存;
|
||
// 写工具逐个执行,执行前后都会让相关读缓存失效,避免模型基于过期画布做决定。
|
||
|
||
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/repository"
|
||
)
|
||
|
||
func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) bool {
|
||
payload := map[string]any{"toolName": call.Function.Name, "callId": call.ID, "arguments": call.Function.Arguments}
|
||
if call.Function.Name == "skill_read_file" {
|
||
var args struct {
|
||
SkillID string `json:"skillId"`
|
||
Path string `json:"path"`
|
||
}
|
||
if json.Unmarshal([]byte(call.Function.Arguments), &args) == nil {
|
||
payload["skillId"], payload["path"] = args.SkillID, args.Path
|
||
for _, skill := range state.Skills {
|
||
if skill.ID == args.SkillID {
|
||
payload["skillName"] = skill.Name
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
kind := "tool_completed"
|
||
if err != nil {
|
||
detail, ok := result.(map[string]any)
|
||
if !ok || detail == nil {
|
||
detail = map[string]any{}
|
||
}
|
||
// 模型漏了必填字段(或参数不是对象)时,即便业务校验抛的是普通 AppError,也按
|
||
// "参数契约错误"处理:把本轮实际暴露的 schema 回给模型,它才改得对。
|
||
// 只在业务侧确实按参数问题拒绝(400/422)时套用:上游 5xx 或网络错误即便同时漏字段,
|
||
// 也该算上游故障,别把锅扣到参数上。已分类的参数错误不再套壳,避免覆盖更精确的反馈。
|
||
var appErr *AppError
|
||
var existingArgumentErr *cloudAgentArgumentError
|
||
if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) > 0 &&
|
||
!errors.As(err, &existingArgumentErr) &&
|
||
errors.As(err, &appErr) && appErr != nil && (appErr.Status == 400 || appErr.Status == 422) {
|
||
err = &cloudAgentFieldArgumentError{
|
||
error: &cloudAgentArgumentError{err},
|
||
Field: strings.Join(missing, ","), Issue: "required",
|
||
}
|
||
}
|
||
message := cloudAgentSafeToolError(err)
|
||
detail["error"] = message
|
||
var admissionErr *cloudAgentMediaAdmissionError
|
||
if errors.As(err, &admissionErr) {
|
||
detail["reason"], detail["nodeId"] = admissionErr.Reason, admissionErr.NodeID
|
||
}
|
||
var argumentErr *cloudAgentArgumentError
|
||
if errors.As(err, &argumentErr) {
|
||
detail["reason"] = "invalid_tool_arguments"
|
||
var fieldErr *cloudAgentFieldArgumentError
|
||
if errors.As(err, &fieldErr) {
|
||
detail["field"], detail["issue"] = fieldErr.Field, fieldErr.Issue
|
||
}
|
||
// Use this run's advertised contract, including its permission scope.
|
||
for _, tool := range state.Canonical.Tools {
|
||
function, _ := tool["function"].(map[string]any)
|
||
if function["name"] == call.Function.Name {
|
||
detail["parameters"] = function["parameters"]
|
||
break
|
||
}
|
||
}
|
||
detail["guidance"] = "本次调用未执行,请按 parameters 修正参数后重试,不要重复提交相同的错误参数"
|
||
if call.Function.Name == "canvas_get_state" {
|
||
detail["exampleArguments"] = map[string]any{}
|
||
}
|
||
if call.Function.Name == "canvas_apply_ops" {
|
||
detail["exampleArguments"] = map[string]any{"snapshotHash": "<canvas_get_state.snapshotHash>", "ops": []any{map[string]any{"type": "add_node", "id": "<new-node-id>", "nodeType": "text", "content": "<content>"}}}
|
||
}
|
||
}
|
||
// 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。
|
||
// allowed 与执行器用的是同一份判定(cloudAgentToolAllowed 是纯函数,结果一致)。
|
||
if class, retryable, requiredAction := cloudAgentToolErrorClass(state.Request, call, err, cloudAgentToolAllowed(state.Request, call.Function.Name)); class != "" {
|
||
detail["errorClass"], detail["errorClassLabel"] = class, cloudAgentToolErrorLabel(class)
|
||
detail["retryable"] = retryable
|
||
if requiredAction != "" {
|
||
detail["requiredAction"] = requiredAction
|
||
}
|
||
payload["errorClass"] = class
|
||
}
|
||
result = detail
|
||
kind = "tool_failed"
|
||
payload["text"] = message
|
||
} 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
|
||
if call.Function.Name == "skill_read_file" && err == nil {
|
||
// SSE/UI needs the read receipt, not another durable copy of skill text.
|
||
if fields, ok := result.(map[string]any); ok {
|
||
receipt := make(map[string]any, len(fields))
|
||
for key, value := range fields {
|
||
if key != "content" {
|
||
receipt[key] = value
|
||
}
|
||
}
|
||
payload["result"] = receipt
|
||
}
|
||
}
|
||
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 != "previs_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 "previs_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
|
||
// that refers to history the model can no longer see.
|
||
func cloudAgentReadResultInContext(state *cloudAgentRuntime, result json.RawMessage) bool {
|
||
if state == nil || len(result) == 0 {
|
||
return false
|
||
}
|
||
// Unit-level callers may exercise the cache helper without constructing a
|
||
// transcript. The real runtime always has the first tool message here.
|
||
if len(state.Canonical.Messages) == 0 {
|
||
return true
|
||
}
|
||
want := string(result)
|
||
for _, message := range state.Canonical.Messages {
|
||
if stringValue(message["role"]) == "tool" && stringValue(message["content"]) == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// cloudAgentBatchableReadTool identifies read calls that are independent of
|
||
// each other and safe to execute from one model tool-call response. Keeping
|
||
// this list limited to the cacheable read contract is important: mutations,
|
||
// approvals, user questions, media and vision calls remain ordered state
|
||
// transitions and must each retain their existing semantics.
|
||
func cloudAgentBatchableReadTool(name string) bool {
|
||
return cloudAgentReadToolReadOnly(name)
|
||
}
|
||
|
||
func cloudAgentInvalidateReadCache(state *cloudAgentRuntime) {
|
||
if state == nil {
|
||
return
|
||
}
|
||
// Canvas writes invalidate only canvas projections. Skill documents,
|
||
// profile preferences, and the model catalog do not change when a canvas is
|
||
// edited, so retaining them avoids re-reading large unrelated tool results.
|
||
for key := range state.ToolReadResults {
|
||
if strings.HasPrefix(key, "canvas_get_state:") || strings.HasPrefix(key, "canvas_read_storyboard:") {
|
||
delete(state.ToolReadResults, key)
|
||
delete(state.ToolReadReplays, key)
|
||
}
|
||
}
|
||
}
|
||
|
||
// advanceCloudAgentReadBatch executes consecutive independent reads from the
|
||
// same model response before returning to the scheduler. The previous path
|
||
// checkpointed after every read, which turned one parallel tool-call response
|
||
// into N scheduler/database transitions. Results still get one tool message
|
||
// per call, in the original order, so every provider contract remains valid.
|
||
func (s *Service) advanceCloudAgentReadBatch(run *model.CloudAgentExecution, state *cloudAgentRuntime) (bool, error) {
|
||
if run == nil || state == nil || (run.Status != "running" && run.Status != "queued") {
|
||
return true, nil
|
||
}
|
||
if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) {
|
||
return false, nil
|
||
}
|
||
type outcome struct {
|
||
call cloudAgentCall
|
||
result any
|
||
err error
|
||
}
|
||
outcomes := make([]outcome, 0, len(state.Calls)-state.CallIndex)
|
||
// Reads are collected before one checkpoint write. During collection, their
|
||
// successful receipts are not in Canonical.Messages yet, so the regular cache
|
||
// layer cannot see that an earlier call in this same batch already returned
|
||
// the full body. Track those keys here to avoid appending the same large result
|
||
// repeatedly when a model emits duplicate parallel reads.
|
||
batchReadResults := make(map[string]bool, len(state.Calls)-state.CallIndex)
|
||
for index := state.CallIndex; index < len(state.Calls); index++ {
|
||
call := state.Calls[index]
|
||
if !cloudAgentBatchableReadTool(call.Function.Name) || !cloudAgentToolAllowed(state.Request, call.Function.Name) {
|
||
break
|
||
}
|
||
call = s.cloudAgentRefreshStepSnapshotHash(run, state, call)
|
||
state.Calls[index] = call
|
||
if call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" {
|
||
state.RuntimeRunID = run.ID
|
||
}
|
||
result, err := cloudAgentReadToolCached(s.repo, run.UserID, state, call, s)
|
||
if err == nil {
|
||
cacheKey := cloudAgentReadCacheKeyForState(s.repo, run.UserID, state, call)
|
||
if batchReadResults[cacheKey] {
|
||
// The first receipt will be appended before this one in the same
|
||
// checkpoint transaction, so this acknowledgement is truthful even
|
||
// though the canonical transcript has not been updated yet.
|
||
result = map[string]any{
|
||
"cacheReplay": true,
|
||
"replayCount": state.ToolReadReplays[cacheKey],
|
||
"message": "该只读结果已在本批工具调用的前序结果中,请直接使用已有结果,不要再次读取",
|
||
}
|
||
} else {
|
||
batchReadResults[cacheKey] = true
|
||
}
|
||
}
|
||
outcomes = append(outcomes, outcome{call: call, result: result, err: err})
|
||
var loopErr *cloudAgentReadLoopError
|
||
if errors.As(err, &loopErr) {
|
||
break
|
||
}
|
||
}
|
||
if len(outcomes) == 0 {
|
||
return false, nil
|
||
}
|
||
|
||
s.storageMu.Lock()
|
||
defer s.storageMu.Unlock()
|
||
err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||
for _, item := range outcomes {
|
||
var readLoopErr *cloudAgentReadLoopError
|
||
if errors.As(item.err, &readLoopErr) {
|
||
// A provider tool-call turn is atomic from the transcript's point of
|
||
// view: every declared tool_call_id needs a tool message, even when a
|
||
// read guard terminates the run. Record the triggering error and then
|
||
// explicit skipped receipts before appending run_failed, otherwise a
|
||
// later resume/replay produces an invalid provider transcript.
|
||
cloudAgentToolResult(current.ID, state, item.call, item.result, item.err)
|
||
for state.CallIndex < len(state.Calls) {
|
||
pending := state.Calls[state.CallIndex]
|
||
cloudAgentToolResult(current.ID, state, pending,
|
||
map[string]any{"skipped": true, "reason": readLoopErr.reasonCode()},
|
||
nil)
|
||
}
|
||
current.Status = "failed"
|
||
current.FailureMessage = truncateRunes(readLoopErr.Error(), 1000)
|
||
cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(current.FailureMessage, 120), state)
|
||
reason := readLoopErr.reasonCode()
|
||
state.event(run.ID, "run_failed", map[string]any{
|
||
"text": current.FailureMessage, "reason": reason,
|
||
"toolName": item.call.Function.Name, "readCount": readLoopErr.Count,
|
||
})
|
||
break
|
||
}
|
||
cloudAgentRecordToolResult(current, state, item.call, item.result, item.err)
|
||
}
|
||
return cloudAgentSave(current, state)
|
||
})
|
||
return true, err
|
||
}
|
||
|
||
func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *cloudAgentRuntime) error {
|
||
if run == nil || state == nil || (run.Status != "running" && run.Status != "queued") {
|
||
return nil
|
||
}
|
||
if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) {
|
||
return s.failCloudAgent(run, state, "Agent 工具调用状态无效,本轮已停止")
|
||
}
|
||
call := state.Calls[state.CallIndex]
|
||
call = s.cloudAgentRefreshStepSnapshotHash(run, state, call)
|
||
state.Calls[state.CallIndex] = call
|
||
if state.Approval != nil && state.Approval.Decision == "" {
|
||
return nil
|
||
}
|
||
if state.Approval != nil && state.Approval.Decision != "" && state.Approval.CallHash != "" && state.Approval.CallHash != cloudAgentApprovalCallHash(call) {
|
||
return s.terminateCloudAgent(run, "审批内容与待执行操作不一致,本轮已停止")
|
||
}
|
||
allowed := cloudAgentToolAllowed(state.Request, call.Function.Name)
|
||
mediaTool := call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split"
|
||
// 媒体工具在所有模式下都进入审批;其他写入只在 request_approval 下审批。
|
||
if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || mediaTool) && state.Approval == nil {
|
||
var plan *cloudAgentMediaPlan
|
||
var modelName string
|
||
var mediaRequest CreateTaskRequest
|
||
var preparedTask *model.Task
|
||
var mediaPreparation *creationTaskPreparation
|
||
policy, err := s.RuntimePolicy()
|
||
if err != nil {
|
||
return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止")
|
||
}
|
||
if call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" {
|
||
mediaCall := cloudAgentMediaCall(call)
|
||
req, prepared, err := s.prepareCloudAgentMedia(run, state, mediaCall)
|
||
if err != nil {
|
||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||
}
|
||
mediaRequest, plan = req, prepared
|
||
// A durable auto checkpoint already contains the exact admitted input and
|
||
// quote. Reusing it avoids a second dry admission after a worker restart.
|
||
if plan.Prepared == nil {
|
||
// Dry admission validates the selected model and prompt limits without a task or charge.
|
||
mediaPreparation = &creationTaskPreparation{}
|
||
req.creationPrepare = mediaPreparation
|
||
preparedTask, err = s.CreateTask(run.UserID, req)
|
||
if err != nil {
|
||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||
}
|
||
// Resolve model-owned defaults once during the dry admission. Both the
|
||
// eventual task and the canvas draft must use this exact resolved input;
|
||
// otherwise auto mode reports a false tool failure for omitted size or
|
||
// duration and makes the model spend another turn repairing its own call.
|
||
if err := applyCloudAgentResolvedMediaDefaults(&req, prepared, preparedTask); err != nil {
|
||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||
}
|
||
mediaRequest, plan = req, prepared
|
||
}
|
||
modelName, err = s.cloudAgentMediaModelName(plan.Args)
|
||
if err != nil {
|
||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||
}
|
||
}
|
||
if plan != nil && state.Request.PermissionMode == "auto" && !mediaTool {
|
||
if plan.Prepared != nil {
|
||
// The draft and quote were already checkpointed. Reuse them after a
|
||
// worker restart instead of dry-admitting and mutating the canvas again.
|
||
latest, err := s.repo.CloudAgent(run.UserID, run.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fresh, err := cloudAgentDecode(latest)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan)
|
||
}
|
||
// Auto media is a direct, server-admitted write. Prepare the draft and
|
||
// its immutable quote in one checkpoint transaction, then release the
|
||
// lock before enqueueCloudAgentTask performs the billed submission.
|
||
s.storageMu.Lock()
|
||
err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||
if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil {
|
||
return err
|
||
}
|
||
canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
doc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc)
|
||
raw, err := json.Marshal(plan.Args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
call.Function.Arguments = string(raw)
|
||
state.Calls[state.CallIndex] = call
|
||
preparedMedia, err := prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
plan.Prepared = preparedMedia
|
||
state.AutoPreparedMedia = preparedMedia
|
||
state.AutoPreparedCallHash = cloudAgentApprovalCallHash(call)
|
||
return cloudAgentSave(current, state)
|
||
})
|
||
s.storageMu.Unlock()
|
||
if err != nil {
|
||
latest, readErr := s.repo.CloudAgent(run.UserID, run.ID)
|
||
if readErr != nil || latest.Revision != run.Revision {
|
||
return err
|
||
}
|
||
fresh, decodeErr := cloudAgentDecode(latest)
|
||
if decodeErr != nil {
|
||
return decodeErr
|
||
}
|
||
return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err)
|
||
}
|
||
latest, err := s.repo.CloudAgent(run.UserID, run.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fresh, err := cloudAgentDecode(latest)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan)
|
||
}
|
||
s.storageMu.Lock()
|
||
defer s.storageMu.Unlock()
|
||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||
var preview cloudAgentApprovalPreview
|
||
var preparedMedia *cloudAgentPreparedMedia
|
||
if plan != nil {
|
||
if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil {
|
||
return err
|
||
}
|
||
canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
doc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc)
|
||
raw, err := json.Marshal(plan.Args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
call.Function.Arguments = string(raw)
|
||
state.Calls[state.CallIndex] = call
|
||
preview = cloudAgentMediaApprovalPreview(plan, modelName)
|
||
preparedMedia, err = prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
var mutationErr error
|
||
switch call.Function.Name {
|
||
case "canvas_create_storyboard":
|
||
storyboardPlan, err := prepareCloudAgentStoryboardCreate(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = storyboardPlan.Preview
|
||
}
|
||
case "canvas_edit_storyboard":
|
||
storyboardPlan, err := prepareCloudAgentStoryboardEdit(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = storyboardPlan.Preview
|
||
}
|
||
case "canvas_create_character":
|
||
characterPlan, err := prepareCloudAgentCharacterCreate(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = characterPlan.Preview
|
||
}
|
||
case "canvas_edit_batch_table":
|
||
batchPlan, err := prepareCloudAgentBatchTableEdit(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = batchPlan.Preview
|
||
}
|
||
case "previs_scene_create":
|
||
previsPlan, err := prepareCloudAgentPrevisSceneCreate(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = previsPlan.Preview
|
||
}
|
||
case "previs_apply_patch":
|
||
previsPlan, err := prepareCloudAgentPrevisApplyPatch(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = previsPlan.Preview
|
||
}
|
||
case "canvas_arrange_nodes":
|
||
arrangePlan, err := prepareCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = arrangePlan.Preview
|
||
}
|
||
default:
|
||
canvasPlan, err := prepareCloudAgentCanvasMutation(repo, run.UserID, state.Request.CanvasID, call)
|
||
mutationErr = err
|
||
if err == nil {
|
||
preview = canvasPlan.Preview
|
||
}
|
||
}
|
||
if mutationErr != nil {
|
||
var argumentErr *cloudAgentArgumentError
|
||
if errors.As(mutationErr, &argumentErr) {
|
||
cloudAgentRecordToolResult(current, state, call, nil, mutationErr)
|
||
return cloudAgentSave(current, state)
|
||
}
|
||
var appErr *AppError
|
||
if errors.As(mutationErr, &appErr) && appErr != nil {
|
||
return failCloudAgentAdmission(current, state, run.ID, mutationErr)
|
||
}
|
||
return mutationErr
|
||
}
|
||
}
|
||
state.Approval = &cloudAgentApproval{ID: fmt.Sprintf("%s-%d-%d", run.ID, state.Step, state.CallIndex), Call: call, CallHash: cloudAgentApprovalCallHash(call), Preview: preview, ModelName: modelName, Prepared: preparedMedia}
|
||
if preparedMedia != nil {
|
||
if err := pinCloudAgentPreparedMedia(repo, run.UserID, run.ID, state.Approval.ID, preparedMedia); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
current.Status = "waiting_approval"
|
||
state.event(run.ID, "approval_requested", map[string]any{"approvalId": state.Approval.ID, "toolName": call.Function.Name, "modelName": modelName, "arguments": json.RawMessage(call.Function.Arguments), "preview": preview, "prepared": preparedMedia.publicView(), "text": preview.Description})
|
||
return cloudAgentSave(current, state)
|
||
})
|
||
}
|
||
if allowed && call.Function.Name == "plan_update" && cloudAgentPlanRequiresFirstApproval(state, call) {
|
||
if preview, ok := cloudAgentPlanApprovalPreview(call); ok {
|
||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||
approvalID := fmt.Sprintf("%s-%d-%d", run.ID, state.Step, state.CallIndex)
|
||
state.Approval = &cloudAgentApproval{ID: approvalID, Call: call, CallHash: cloudAgentApprovalCallHash(call), Preview: preview}
|
||
current.Status = "waiting_approval"
|
||
state.event(run.ID, "approval_requested", map[string]any{"approvalId": approvalID, "toolName": call.Function.Name, "arguments": json.RawMessage(call.Function.Arguments), "preview": preview, "text": preview.Description})
|
||
return cloudAgentSave(current, state)
|
||
})
|
||
}
|
||
}
|
||
if allowed && (call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split") && state.Approval != nil && state.Approval.Decision == "approve" {
|
||
return s.advanceCloudAgentMedia(run, state, cloudAgentMediaCall(call))
|
||
}
|
||
policy, err := s.RuntimePolicy()
|
||
if err != nil {
|
||
return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止")
|
||
}
|
||
// 看图的资源与能力校验在写事务外完成;真实图片只在模型任务执行时读取。
|
||
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)
|
||
if errors.Is(inspectionErr, errCloudAgentImageInspectionBudget) {
|
||
return s.failCloudAgent(run, state, cloudAgentImageInspectionBudgetMessage)
|
||
}
|
||
}
|
||
// 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
|
||
var skillErr error
|
||
if allowed && (call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" || call.Function.Name == "image_annotation_render") {
|
||
state.RuntimeRunID = run.ID
|
||
if call.Function.Name == "image_annotation_render" {
|
||
skillResult, skillErr = cloudAgentReadTool(s.repo, run.UserID, state, call, s)
|
||
} else {
|
||
// 技能文件和模型目录都是稳定的只读结果。统一走检查点缓存,
|
||
// 使重复调用不会再次访问文件系统/数据库,也不会把大结果重复
|
||
// 写入后续模型上下文。
|
||
skillResult, skillErr = cloudAgentReadToolCached(s.repo, run.UserID, state, call, s)
|
||
}
|
||
}
|
||
s.storageMu.Lock()
|
||
defer s.storageMu.Unlock()
|
||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||
var result any
|
||
var toolErr error
|
||
switch {
|
||
case !allowed:
|
||
// 幻觉出来的工具名与"真被权限挡住"要分开反馈:前者模型根本不该发这个调用,
|
||
// 后者是授权边界问题。两者仍然一律拒绝,只是给模型的下一步更明确。
|
||
if !cloudAgentPlatformToolNames()[call.Function.Name] {
|
||
toolErr = BadAuthRequest("模型调用了不存在的工具「" + truncateRunes(call.Function.Name, 60) + "」,本轮已拒绝;请只使用本轮工具表里列出的工具")
|
||
} else {
|
||
toolErr = BadAuthRequest("工具未获本轮权限授权")
|
||
}
|
||
case call.Function.Name == "canvas_apply_ops":
|
||
result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||
case call.Function.Name == "canvas_arrange_nodes":
|
||
result, toolErr = applyCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||
case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard":
|
||
result, toolErr = applyCloudAgentStoryboardMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||
case call.Function.Name == "canvas_create_character":
|
||
result, toolErr = applyCloudAgentCharacterCreate(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||
case call.Function.Name == "canvas_edit_batch_table":
|
||
result, toolErr = applyCloudAgentBatchTableMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||
case call.Function.Name == "previs_scene_create", call.Function.Name == "previs_apply_patch":
|
||
result, toolErr = applyCloudAgentPrevisMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||
case call.Function.Name == "canvas_inspect_image":
|
||
result, toolErr = inspectionResult, inspectionErr
|
||
if toolErr == nil && inspectionResult != nil {
|
||
if inspection, ok := inspectionResult.(cloudAgentImageInspection); ok {
|
||
state.markCanvasImageInspection(stringValue(inspection.Receipt["nodeId"]), strings.TrimSpace(inspection.ImageURL) != "")
|
||
if inspection.CacheKey != "" {
|
||
if state.ImageInspectionReads == nil {
|
||
state.ImageInspectionReads = map[string]int{}
|
||
}
|
||
state.ImageInspectionReads[inspection.CacheKey]++
|
||
}
|
||
}
|
||
}
|
||
case call.Function.Name == "skill_read_file", call.Function.Name == "model_list", call.Function.Name == "image_annotation_render":
|
||
result, toolErr = skillResult, skillErr
|
||
default:
|
||
result, toolErr = cloudAgentReadToolCached(repo, run.UserID, state, call)
|
||
}
|
||
if toolErr == nil && cloudAgentWrite(call.Function.Name) {
|
||
// A successful canvas mutation changes the read model. Do not replay a
|
||
// pre-mutation canvas snapshot later in the same Agent run.
|
||
cloudAgentInvalidateReadCache(state)
|
||
}
|
||
var readLoopErr *cloudAgentReadLoopError
|
||
if errors.As(toolErr, &readLoopErr) {
|
||
cloudAgentRecordToolResult(current, state, call, result, toolErr)
|
||
current.Status = "failed"
|
||
current.FailureMessage = truncateRunes(readLoopErr.Error(), 1000)
|
||
cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(current.FailureMessage, 120), state)
|
||
reason := readLoopErr.reasonCode()
|
||
state.event(run.ID, "run_failed", map[string]any{
|
||
"text": current.FailureMessage, "reason": reason,
|
||
"toolName": call.Function.Name, "readCount": readLoopErr.Count,
|
||
})
|
||
return cloudAgentSave(current, state)
|
||
}
|
||
if call.Function.Name == "plan_update" && toolErr == nil {
|
||
state.event(run.ID, "plan_updated", map[string]any{"items": state.Plan, "pendingTitles": cloudAgentPendingPlanItems(state.Plan)})
|
||
}
|
||
if call.Function.Name == "ask_user" && toolErr == nil {
|
||
payload, _ := result.(map[string]any)
|
||
if payload["phase"] == "question" {
|
||
state.event(run.ID, "user_question", payload)
|
||
cloudAgentRecordToolResult(current, state, call, result, nil)
|
||
skipRemainingCloudAgentCalls(run.ID, state)
|
||
current.Status = "completed"
|
||
return cloudAgentSave(current, state)
|
||
}
|
||
// The server-side round limit turns further questions into a normal
|
||
// tool result so the model must continue with safe defaults.
|
||
cloudAgentRecordToolResult(current, state, call, result, nil)
|
||
return cloudAgentSave(current, state)
|
||
}
|
||
cloudAgentRecordToolResult(current, state, call, result, toolErr)
|
||
return cloudAgentSave(current, state)
|
||
})
|
||
}
|