Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_runtime_tools.go

756 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 画布 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)
})
}