chore: commit workspace updates
This commit is contained in:
971
backend/internal/app/cloud_agent_runtime.go
Normal file
971
backend/internal/app/cloud_agent_runtime.go
Normal file
@@ -0,0 +1,971 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
const cloudAgentMaxSteps = 8
|
||||
|
||||
// A deterministic checkpoint failure must not be retried forever like a transient DB error.
|
||||
var errCloudAgentCheckpoint = errors.New("invalid Agent checkpoint")
|
||||
|
||||
type CloudAgentEvent struct {
|
||||
EventID string `json:"eventId"`
|
||||
RunID string `json:"runId"`
|
||||
Seq int `json:"seq"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
type cloudAgentCall struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
type cloudAgentApproval struct {
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
ID string `json:"approvalId"`
|
||||
Call cloudAgentCall `json:"call"`
|
||||
Decision string `json:"decision,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type cloudAgentRuntime struct {
|
||||
Request CloudAgentRequest `json:"request"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
TextHistory []providerTextMessage `json:"textHistory,omitempty"`
|
||||
Skills []cloudAgentSkill `json:"skills"`
|
||||
SkillReads map[string]bool `json:"skillReads,omitempty"`
|
||||
Canonical canonicalAgentRequest `json:"canonical"`
|
||||
ActiveTaskID string `json:"activeTaskId"`
|
||||
ActiveTextDraft string `json:"activeTextDraft,omitempty"`
|
||||
MediaTaskID string `json:"mediaTaskId,omitempty"`
|
||||
TaskIDs []string `json:"taskIds"`
|
||||
Step int `json:"step"`
|
||||
Generations int `json:"generations"`
|
||||
VideoSeconds int `json:"videoSeconds"`
|
||||
Calls []cloudAgentCall `json:"calls"`
|
||||
CallIndex int `json:"callIndex"`
|
||||
Approval *cloudAgentApproval `json:"approval,omitempty"`
|
||||
Decisions map[string]string `json:"decisions"`
|
||||
Events []CloudAgentEvent `json:"events"`
|
||||
}
|
||||
|
||||
func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgentState) error {
|
||||
var input struct {
|
||||
TextHistory []providerTextMessage `json:"textHistory"`
|
||||
Requests struct {
|
||||
Canonical canonicalAgentRequest `json:"canonical"`
|
||||
} `json:"agentRequests"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
|
||||
return err
|
||||
}
|
||||
state := cloudAgentRuntime{Request: initial.Request, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, TextHistory: input.TextHistory, Skills: initial.Skills, Canonical: input.Requests.Canonical, ActiveTaskID: task.ID, TaskIDs: []string{task.ID}, Step: 1, Decisions: map[string]string{}, Events: []CloudAgentEvent{}}
|
||||
if len(initial.Skills) > 0 {
|
||||
state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "text": fmt.Sprintf("已从用户技能库固定加载 %d 个技能", len(initial.Skills))})
|
||||
}
|
||||
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)
|
||||
}
|
||||
func (state *cloudAgentRuntime) event(id, kind string, payload map[string]any) {
|
||||
seq := len(state.Events) + 1
|
||||
state.Events = append(state.Events, CloudAgentEvent{EventID: fmt.Sprintf("%s:%d", id, seq), RunID: id, Seq: seq, Type: kind, Payload: payload, CreatedAt: time.Now()})
|
||||
}
|
||||
func cloudAgentDecode(run *model.CloudAgentExecution) (cloudAgentRuntime, error) {
|
||||
var state cloudAgentRuntime
|
||||
if run == nil || strings.TrimSpace(run.StateJSON) == "" {
|
||||
return state, errors.New("Agent runtime state is empty")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(run.StateJSON), &state); err != nil {
|
||||
return state, fmt.Errorf("decode Agent runtime state: %w", err)
|
||||
}
|
||||
if err := validateCloudAgentRuntime(run, &state); err != nil {
|
||||
return state, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgentRuntime) error {
|
||||
if run == nil || state == nil {
|
||||
return errors.New("Agent runtime state is missing")
|
||||
}
|
||||
if run.ID == "" {
|
||||
// Package-level tool tests use an in-memory runtime without a durable
|
||||
// execution identity. Durable rows are always validated below.
|
||||
return nil
|
||||
}
|
||||
// A zero-value runtime is useful to exercise the skill reader in isolation.
|
||||
// Persisted executions always have a database id, so keep the durable-state
|
||||
// invariant strict without making those package-level helpers impossible to
|
||||
// test independently.
|
||||
if run.ID != "" && (state.Request.CanvasID == "" || state.Request.Prompt == "" || state.Request.PermissionMode == "") {
|
||||
return errors.New("Agent runtime request is incomplete")
|
||||
}
|
||||
if run.ID != "" {
|
||||
if err := validateCloudAgentRequest(&state.Request); err != nil {
|
||||
return fmt.Errorf("invalid Agent runtime request: %w", err)
|
||||
}
|
||||
}
|
||||
if state.Step < 0 || state.Step > cloudAgentMaxSteps+1 || state.Generations < 0 || state.VideoSeconds < 0 {
|
||||
return errors.New("Agent runtime budget or step is invalid")
|
||||
}
|
||||
if state.Generations > state.Request.Budget.MaxGenerationTasks || state.VideoSeconds > state.Request.Budget.MaxVideoSeconds {
|
||||
return errors.New("Agent runtime generation budget is invalid")
|
||||
}
|
||||
if state.CallIndex < 0 || state.CallIndex > len(state.Calls) || len(state.Calls) > 8 {
|
||||
return errors.New("Agent runtime call cursor is invalid")
|
||||
}
|
||||
if state.ActiveTaskID != "" && state.MediaTaskID != "" {
|
||||
return errors.New("Agent runtime has multiple active tasks")
|
||||
}
|
||||
if len(state.TaskIDs) == 0 || len(state.TaskIDs) > 64 {
|
||||
return errors.New("Agent runtime task history is invalid")
|
||||
}
|
||||
seenTasks := make(map[string]struct{}, len(state.TaskIDs))
|
||||
for _, taskID := range state.TaskIDs {
|
||||
if err := validateCloudAgentID(taskID, "任务 ID", 80); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := seenTasks[taskID]; exists {
|
||||
return errors.New("Agent runtime task history contains duplicates")
|
||||
}
|
||||
seenTasks[taskID] = struct{}{}
|
||||
}
|
||||
if state.ActiveTaskID != "" && !cloudAgentContainsString(state.TaskIDs, state.ActiveTaskID) {
|
||||
return errors.New("Agent runtime active task is not in task history")
|
||||
}
|
||||
if state.MediaTaskID != "" {
|
||||
if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || state.Calls[state.CallIndex].Function.Name != "generate_media" {
|
||||
return errors.New("Agent runtime media task is not attached to current call")
|
||||
}
|
||||
}
|
||||
if state.Decisions == nil || state.Events == nil {
|
||||
return errors.New("Agent runtime maps are missing")
|
||||
}
|
||||
if len(state.Events) > 4096 {
|
||||
return errors.New("Agent runtime event history is too large")
|
||||
}
|
||||
for index, event := range state.Events {
|
||||
if event.RunID != run.ID || event.Seq != index+1 || event.EventID == "" || event.Type == "" || event.Payload == nil || event.CreatedAt.IsZero() {
|
||||
return errors.New("Agent runtime event history is invalid")
|
||||
}
|
||||
if err := validateCloudAgentID(event.EventID, "事件 ID", 240); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(event.Payload)
|
||||
if err != nil || len(raw) > 128<<10 {
|
||||
return errors.New("Agent runtime event payload is too large")
|
||||
}
|
||||
}
|
||||
for _, call := range state.Calls {
|
||||
if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil || call.Function.Name == "" || utf8.RuneCountInString(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) {
|
||||
return errors.New("Agent runtime tool call is invalid")
|
||||
}
|
||||
if len(call.Function.Arguments) > 32000 {
|
||||
return errors.New("Agent runtime tool arguments are too large")
|
||||
}
|
||||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil {
|
||||
return errors.New("Agent runtime tool arguments are invalid")
|
||||
}
|
||||
}
|
||||
if state.Approval != nil {
|
||||
if err := validateCloudAgentID(state.Approval.ID, "审批 ID", 200); err != nil || state.CallIndex >= len(state.Calls) {
|
||||
return errors.New("Agent runtime approval is invalid")
|
||||
}
|
||||
current := state.Calls[state.CallIndex]
|
||||
if state.Approval.Call.ID != current.ID || state.Approval.Call.Function.Name != current.Function.Name || state.Approval.Call.Function.Arguments != current.Function.Arguments {
|
||||
return errors.New("Agent runtime approval does not match current call")
|
||||
}
|
||||
if state.Approval.Decision != "" && state.Approval.Decision != "approve" && state.Approval.Decision != "reject" {
|
||||
return errors.New("Agent runtime approval decision is invalid")
|
||||
}
|
||||
}
|
||||
if state.CallIndex == len(state.Calls) && state.Approval != nil {
|
||||
return errors.New("Agent runtime has approval without a pending call")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloudAgentContainsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func cloudAgentSave(run *model.CloudAgentExecution, state *cloudAgentRuntime) error {
|
||||
if run == nil || state == nil {
|
||||
return errors.New("Agent runtime state is missing")
|
||||
}
|
||||
if run.ID != "" {
|
||||
if err := validateCloudAgentRuntime(run, state); err != nil {
|
||||
return fmt.Errorf("%w: %v", errCloudAgentCheckpoint, err)
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errCloudAgentCheckpoint, err)
|
||||
}
|
||||
if len(raw) > 512<<10 {
|
||||
return fmt.Errorf("%w: Agent 状态超过 512KB 上限", errCloudAgentCheckpoint)
|
||||
}
|
||||
run.CanvasID, run.ActiveTaskID, run.MediaTaskID = state.Request.CanvasID, state.ActiveTaskID, state.MediaTaskID
|
||||
run.StateJSON = string(raw)
|
||||
return nil
|
||||
}
|
||||
func (s *Service) cloudAgentExecutionOutput(task *model.Task, initial cloudAgentState) (*CloudAgentRun, error) {
|
||||
run, err := s.repo.CloudAgent(task.UserID, task.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, stateErr := cloudAgentDecode(run)
|
||||
if stateErr != nil {
|
||||
// A terminal failed run must remain readable even if its durable runtime
|
||||
// blob was damaged. Do not invent permissions or approval state; expose
|
||||
// only the identity available from the original task input.
|
||||
state = cloudAgentRuntime{Request: initial.Request, ParentID: initial.ParentID, Skills: initial.Skills, TaskIDs: []string{task.ID}, Events: []CloudAgentEvent{}}
|
||||
}
|
||||
out := agentRunOutput(task, initial)
|
||||
out.Status = run.Status
|
||||
out.Revision, out.CleanupPending, out.FailureMessage = run.Revision, run.CleanupPending, run.FailureMessage
|
||||
out.UpdatedAt = run.UpdatedAt
|
||||
out.Events = state.Events
|
||||
out.Approval = state.Approval
|
||||
if cloudAgentRunTerminal(run.Status) {
|
||||
out.Approval = nil
|
||||
}
|
||||
out.Step = state.Step
|
||||
if stateErr == nil && state.ActiveTaskID != "" && (run.Status == "running" || run.Status == "queued") {
|
||||
active, err := s.repo.TaskForUser(task.UserID, state.ActiveTaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active.TextDraft != "" {
|
||||
out.ActiveMessage = map[string]string{"messageId": active.ID, "text": active.TextDraft}
|
||||
}
|
||||
}
|
||||
out.Skills = make([]cloudAgentSkill, 0, len(state.Skills))
|
||||
for _, skill := range state.Skills {
|
||||
skill.Instruction = ""
|
||||
skill.Files = nil
|
||||
out.Skills = append(out.Skills, skill)
|
||||
}
|
||||
orders, err := s.repo.BillingOrdersByTaskIDs(task.UserID, state.TaskIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, order := range orders {
|
||||
out.SpentCredits += float64(order.AmountMicrocredits) / float64(CreditScale)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Runs one bounded transition at a time; no model HTTP call or approval wait holds a DB lock.
|
||||
func (s *Service) advanceCloudAgentByID(userID, id string) error {
|
||||
// Do not decode the task input/runtime before checking for an existing
|
||||
// execution. A damaged runtime must be terminally recoverable, not
|
||||
// accidentally replaced by a fresh execution row.
|
||||
task, err := s.repo.TaskForUser(userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Operation != cloudAgentOperation {
|
||||
return kernel.NotFound("Agent 运行不存在")
|
||||
}
|
||||
run, lookupErr := s.repo.CloudAgent(userID, id)
|
||||
if errors.Is(lookupErr, gorm.ErrRecordNotFound) {
|
||||
_, initial, taskErr := s.cloudAgentTask(userID, id)
|
||||
if taskErr != nil {
|
||||
return taskErr
|
||||
}
|
||||
if err := s.ensureCloudAgentExecution(task, initial); err != nil {
|
||||
return err
|
||||
}
|
||||
run, lookupErr = s.repo.CloudAgent(userID, id)
|
||||
}
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
return s.advanceCloudAgent(run)
|
||||
}
|
||||
func (s *Service) advanceCloudAgents() {
|
||||
s.agentSchedulerMu.Lock()
|
||||
defer s.agentSchedulerMu.Unlock()
|
||||
roots, err := s.repo.CloudAgentRoots()
|
||||
if err != nil {
|
||||
log.Printf("agent recovery: %v", err)
|
||||
return
|
||||
}
|
||||
for _, task := range roots {
|
||||
_, state, e := s.cloudAgentTask(task.UserID, task.ID)
|
||||
if e == nil {
|
||||
e = s.ensureCloudAgentExecution(&task, state)
|
||||
}
|
||||
if e != nil {
|
||||
log.Printf("agent recovery %s: %v", task.ID, e)
|
||||
}
|
||||
}
|
||||
runs, err := s.repo.ActiveCloudAgentsAfter(s.agentSchedulerCursor, 50)
|
||||
if err == nil && len(runs) == 0 && s.agentSchedulerCursor != "" {
|
||||
s.agentSchedulerCursor = ""
|
||||
runs, err = s.repo.ActiveCloudAgentsAfter("", 50)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("agent scheduler: %v", err)
|
||||
return
|
||||
}
|
||||
for i := range runs {
|
||||
s.agentSchedulerCursor = runs[i].ID
|
||||
if err = s.advanceCloudAgent(&runs[i]); err != nil && !errors.Is(err, repository.ErrCreationConflict) {
|
||||
log.Printf("agent transition %s: %v", runs[i].ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error) {
|
||||
defer func() {
|
||||
if errors.Is(err, errCloudAgentCheckpoint) {
|
||||
err = s.terminateCloudAgent(run, "Agent 上下文或执行记录超过安全限制,本轮已停止;已有任务结果保留在任务中心")
|
||||
}
|
||||
}()
|
||||
if run.CleanupPending {
|
||||
return s.finishCloudAgentCleanup(context.Background(), run)
|
||||
}
|
||||
if run.Status != "running" && run.Status != "queued" {
|
||||
return nil
|
||||
}
|
||||
state, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
return s.terminateCloudAgent(run, "Agent 运行状态损坏,本轮已停止")
|
||||
}
|
||||
if state.ActiveTaskID != "" {
|
||||
task, err := s.repo.TaskForUser(run.UserID, state.ActiveTaskID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return s.terminateCloudAgent(run, "Agent 模型任务已不存在,本轮已停止")
|
||||
}
|
||||
if err != nil {
|
||||
return err // Transient database failures must not terminate a live task.
|
||||
}
|
||||
if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning {
|
||||
// 将已持久化的模型增量转成 Agent 事件;不拆分完整答案伪装成流式。
|
||||
if task.TextDraft != state.ActiveTextDraft {
|
||||
delta := ""
|
||||
eventType := "assistant_snapshot"
|
||||
payload := map[string]any{"messageId": task.ID, "text": task.TextDraft, "replace": true}
|
||||
if strings.HasPrefix(task.TextDraft, state.ActiveTextDraft) {
|
||||
delta = strings.TrimPrefix(task.TextDraft, state.ActiveTextDraft)
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
eventType = "assistant_delta"
|
||||
payload = map[string]any{"messageId": task.ID, "text": delta}
|
||||
}
|
||||
if err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||||
state.event(run.ID, eventType, payload)
|
||||
state.ActiveTextDraft = task.TextDraft
|
||||
return cloudAgentSave(current, &state)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var result struct {
|
||||
Text string `json:"text"`
|
||||
ToolCalls []cloudAgentCall `json:"toolCalls"`
|
||||
Legacy []cloudAgentCall `json:"tool_calls"`
|
||||
}
|
||||
if task.Status == model.TaskStatusSucceeded {
|
||||
if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil {
|
||||
return s.terminateCloudAgent(run, "模型任务结果损坏,本轮已停止")
|
||||
}
|
||||
calls := result.ToolCalls
|
||||
if len(calls) == 0 {
|
||||
calls = result.Legacy
|
||||
}
|
||||
if len(result.Text) > 32000 || len(calls) > 8 {
|
||||
return s.terminateCloudAgent(run, "模型输出超出 Agent 单步限制")
|
||||
}
|
||||
if err := validateCloudAgentCalls(calls); err != nil {
|
||||
return s.terminateCloudAgent(run, "模型返回了无效或重复的工具调用")
|
||||
}
|
||||
result.ToolCalls = calls
|
||||
}
|
||||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||||
if task.Status != model.TaskStatusSucceeded {
|
||||
current.Status = "failed"
|
||||
text, reason := cloudAgentModelFailure(task)
|
||||
state.event(run.ID, "run_failed", map[string]any{"text": text, "reason": reason, "taskId": task.ID})
|
||||
return cloudAgentSave(current, &state)
|
||||
}
|
||||
calls := result.ToolCalls
|
||||
if result.Text != "" {
|
||||
state.event(run.ID, "assistant_message", map[string]any{"messageId": task.ID, "text": result.Text})
|
||||
if len(calls) == 0 {
|
||||
state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": result.Text})
|
||||
}
|
||||
}
|
||||
state.ActiveTaskID = ""
|
||||
state.Calls = calls
|
||||
state.CallIndex = 0
|
||||
if len(calls) > 0 {
|
||||
state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": result.Text, "tool_calls": calls})
|
||||
}
|
||||
if len(calls) == 0 {
|
||||
current.Status = "completed"
|
||||
}
|
||||
return cloudAgentSave(current, &state)
|
||||
})
|
||||
}
|
||||
if state.CallIndex < len(state.Calls) {
|
||||
return s.advanceCloudAgentTool(run, &state)
|
||||
}
|
||||
if state.Step >= cloudAgentMaxSteps {
|
||||
return s.failCloudAgent(run, &state, "达到 8 次模型调用上限,本轮已停止")
|
||||
}
|
||||
input := map[string]any{"mode": "text", "prompt": state.Request.Prompt, "agentRequests": map[string]any{"canonical": state.Canonical}, "config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)}, "textOptions": map[string]any{"stream": true, "thinking": state.Request.Thinking}}
|
||||
raw, _ := json.Marshal(state.Canonical)
|
||||
if len(raw) > 192<<10 {
|
||||
return s.failCloudAgent(run, &state, "模型上下文超过 192KB 上限")
|
||||
}
|
||||
req := CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_text", Operation: "cloud_agent_step", Prompt: state.Request.Prompt, Model: state.Request.Model, LogicalModelID: state.Request.LogicalModelID, Input: input}
|
||||
return s.enqueueCloudAgentTask(run, &state, req, nil)
|
||||
}
|
||||
func validateCloudAgentCalls(calls []cloudAgentCall) error {
|
||||
seen := make(map[string]bool, len(calls))
|
||||
for _, call := range calls {
|
||||
if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil || seen[call.ID] || call.Function.Name == "" || len(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) {
|
||||
return errors.New("invalid Agent tool call")
|
||||
}
|
||||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil || len(call.Function.Arguments) > 32000 {
|
||||
return errors.New("invalid Agent tool arguments")
|
||||
}
|
||||
seen[call.ID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) terminateCloudAgent(run *model.CloudAgentExecution, message string) error {
|
||||
if run == nil {
|
||||
return errors.New(message)
|
||||
}
|
||||
if err := s.repo.MarkCloudAgentFailed(run.UserID, run.ID, run.Revision, message); err != nil && !errors.Is(err, repository.ErrCreationConflict) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) failCloudAgent(run *model.CloudAgentExecution, state *cloudAgentRuntime, message string) error {
|
||||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
current.Status = "failed"
|
||||
state.event(run.ID, "run_failed", map[string]any{"text": message})
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
|
||||
// Only expose known failure categories; raw provider errors can contain URLs and credentials.
|
||||
func cloudAgentModelFailure(task *model.Task) (string, string) {
|
||||
detail, reason := "模型任务未成功", "model_task_failed"
|
||||
raw := strings.ToLower(task.Error)
|
||||
switch {
|
||||
case strings.Contains(raw, "connection reset by peer"):
|
||||
detail, reason = "模型连接被对端或中间网络设备重置", "model_connection_reset"
|
||||
case strings.Contains(raw, "timeout"), strings.Contains(raw, "deadline exceeded"):
|
||||
detail, reason = "模型请求超时", "model_request_timeout"
|
||||
case strings.Contains(raw, "connection refused"):
|
||||
detail, reason = "无法连接模型服务(连接被拒绝)", "model_connection_refused"
|
||||
}
|
||||
return detail + ";本轮已停止。请在任务中心检查模型任务 " + task.ID, reason
|
||||
}
|
||||
|
||||
func cloudAgentSafeToolError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) && appErr != nil {
|
||||
message := strings.TrimSpace(appErr.Message)
|
||||
if cloudAgentSafeUserMessage(message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return "工具执行失败,请检查输入或稍后重试"
|
||||
}
|
||||
|
||||
func cloudAgentSafeUserMessage(message string) bool {
|
||||
if message == "" || !utf8.ValidString(message) || strings.ContainsAny(message, "\x00\r\n") || utf8.RuneCountInString(message) > 240 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(message)
|
||||
for _, marker := range []string{
|
||||
"http://", "https://", "ftp://", "file://", "authorization", "cookie", "secret", "token", "api_key", "apikey", "x-api-key",
|
||||
"/var/", "/tmp/", "\\", "stack trace", "traceback", " at ", "sql:", "sqlite", "postgres",
|
||||
} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cloudAgentSafeMediaTaskError preserves a short, user-facing task diagnostic
|
||||
// while refusing provider details that commonly contain URLs, credentials, or
|
||||
// internal request metadata. Task.Error is not a safe presentation field.
|
||||
func cloudAgentSafeMediaTaskError(task *model.Task) string {
|
||||
if task == nil {
|
||||
return "媒体任务未成功"
|
||||
}
|
||||
detail := strings.TrimSpace(task.Error)
|
||||
if detail == "" || !utf8.ValidString(detail) || strings.ContainsAny(detail, "\r\n\x00") {
|
||||
return "媒体任务未成功"
|
||||
}
|
||||
lower := strings.ToLower(detail)
|
||||
for _, marker := range []string{
|
||||
"http://", "https://", "ftp://", "authorization", "cookie", "secret", "token", "api_key", "apikey", "x-api-key",
|
||||
} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return "媒体任务未成功"
|
||||
}
|
||||
}
|
||||
runes := []rune(detail)
|
||||
if len(runes) > 240 {
|
||||
detail = string(runes[:240]) + "…"
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) {
|
||||
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 = map[string]any{}
|
||||
}
|
||||
message := cloudAgentSafeToolError(err)
|
||||
detail["error"] = message
|
||||
result = detail
|
||||
kind = "tool_failed"
|
||||
payload["text"] = message
|
||||
} else {
|
||||
payload["text"] = "工具执行成功"
|
||||
}
|
||||
raw, _ := json.Marshal(result)
|
||||
payload["result"] = result
|
||||
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)})
|
||||
state.CallIndex++
|
||||
state.Approval = nil
|
||||
}
|
||||
func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *cloudAgentRuntime) error {
|
||||
if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) {
|
||||
return s.failCloudAgent(run, state, "Agent 工具调用状态无效,本轮已停止")
|
||||
}
|
||||
call := state.Calls[state.CallIndex]
|
||||
if state.Approval != nil && state.Approval.Decision == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := cloudAgentToolAllowed(state.Request, call.Function.Name)
|
||||
if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || call.Function.Name == "generate_media") && state.Approval == nil {
|
||||
var plan *cloudAgentMediaPlan
|
||||
var modelName string
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止")
|
||||
}
|
||||
if call.Function.Name == "generate_media" {
|
||||
req, prepared, err := s.prepareCloudAgentMedia(run, state, call)
|
||||
if err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
// Dry admission validates the selected model and prompt limits without a task or charge.
|
||||
req.creationPrepare = &creationTaskPreparation{}
|
||||
if _, err := s.CreateTask(run.UserID, req); err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
plan = prepared
|
||||
modelName, err = s.cloudAgentMediaModelName(plan.Args)
|
||||
if err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 = cloudAgentCanvasHash(doc)
|
||||
raw, err := json.Marshal(plan.Args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
call.Function.Arguments = string(raw)
|
||||
state.Calls[state.CallIndex] = call
|
||||
}
|
||||
state.Approval = &cloudAgentApproval{ID: fmt.Sprintf("%s-%d-%d", run.ID, state.Step, state.CallIndex), Call: call, ModelName: modelName}
|
||||
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), "text": "请求执行 " + call.Function.Name})
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
if allowed && call.Function.Name == "generate_media" && state.Approval != nil && state.Approval.Decision == "approve" {
|
||||
return s.advanceCloudAgentMedia(run, state, call)
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止")
|
||||
}
|
||||
var modelList any
|
||||
if allowed && call.Function.Name == "model_list" {
|
||||
models, e := s.cloudAgentModelList()
|
||||
if e != nil {
|
||||
return s.terminateCloudAgent(run, "Agent 模型目录不可用,本轮已停止")
|
||||
}
|
||||
modelList = models
|
||||
}
|
||||
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:
|
||||
toolErr = BadAuthRequest("工具未获本轮权限授权")
|
||||
case state.Approval != nil && state.Approval.Decision == "reject":
|
||||
toolErr = BadAuthRequest("用户拒绝本次操作:" + state.Approval.Reason)
|
||||
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 == "model_list":
|
||||
result = modelList
|
||||
default:
|
||||
result, toolErr = cloudAgentReadTool(repo, run.UserID, state, call)
|
||||
}
|
||||
cloudAgentToolResult(run.ID, state, call, result, toolErr)
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *cloudAgentRuntime, req CreateTaskRequest, media *cloudAgentMediaPlan) error {
|
||||
orders, err := s.repo.BillingOrdersByTaskIDs(run.UserID, state.TaskIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remaining := int64(math.Floor(state.Request.Budget.MaxCredits * float64(CreditScale)))
|
||||
for _, order := range orders {
|
||||
remaining -= order.AmountMicrocredits
|
||||
}
|
||||
if remaining < 0 {
|
||||
return s.failCloudAgent(run, state, "Agent 累计预算已耗尽")
|
||||
}
|
||||
prepare := &creationTaskPreparation{}
|
||||
req.admission = &taskAdmission{ID: cloudAgentID(run.UserID, fmt.Sprintf("%s:task:%d", run.ID, len(state.TaskIDs))), MaxCharge: remaining}
|
||||
req.creationPrepare = prepare
|
||||
task, err := s.CreateTask(run.UserID, req)
|
||||
if err != nil {
|
||||
if media != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
return s.failCloudAgent(run, state, cloudAgentSafeToolError(err))
|
||||
}
|
||||
var input map[string]any
|
||||
if err = json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
|
||||
if media != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if media != nil {
|
||||
requested, _ := req.Input["config"].(map[string]any)
|
||||
resolved, _ := input["config"].(map[string]any)
|
||||
for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "count", "videoGenerateAudio"} {
|
||||
if value := stringValue(requested[key]); value != "" && !strings.EqualFold(value, stringValue(resolved[key])) {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("模型解析后的生成规格与审批参数不同,请重新读取目录并审批"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = s.protectTaskSecrets(input); err != nil {
|
||||
if media != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
if media != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
task.InputJSON = string(raw)
|
||||
if prepare.Order != nil {
|
||||
task.BillingOrderID = prepare.Order.ID
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
if media != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
err = s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||||
if media != nil {
|
||||
if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, media, task, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := createTaskWithStorageQuotaRepository(repo, task, prepare.Order, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
state.TaskIDs = append(state.TaskIDs, task.ID)
|
||||
if media != nil {
|
||||
state.MediaTaskID = task.ID
|
||||
state.Generations++
|
||||
state.VideoSeconds += media.Args.Duration
|
||||
state.event(run.ID, "generation_task_created", map[string]any{"toolName": "generate_media", "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"})
|
||||
} else {
|
||||
state.ActiveTaskID = task.ID
|
||||
state.Step++
|
||||
}
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
if err != nil && media != nil {
|
||||
// Rollback may have happened after checkpoint edits. Reload before recording
|
||||
// a tool failure; never turn a stale worker revision into a second result.
|
||||
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)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cloudAgentRuntime, phase string, submitted, terminal bool, err error) error {
|
||||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) {
|
||||
current.Status = "failed"
|
||||
state.event(run.ID, "run_failed", map[string]any{"text": "Agent 媒体调用状态无效,本轮已停止"})
|
||||
return cloudAgentSave(current, state)
|
||||
}
|
||||
cloudAgentToolResult(run.ID, state, state.Calls[state.CallIndex], map[string]any{"phase": phase, "taskSubmitted": submitted}, err)
|
||||
if submitted {
|
||||
state.MediaTaskID = ""
|
||||
}
|
||||
if terminal {
|
||||
current.Status = "failed"
|
||||
state.event(run.ID, "run_failed", map[string]any{"text": "媒体任务已提交,但结果处理失败;任务不会自动重试"})
|
||||
}
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) advanceCloudAgentMedia(run *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall) error {
|
||||
if state.MediaTaskID != "" {
|
||||
task, err := s.repo.TaskForUser(run.UserID, state.MediaTaskID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return s.cloudAgentMediaError(run, state, "completion", true, true, BadAuthRequest("媒体任务不存在或已失去归属,结果未回写画布"))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning {
|
||||
return nil
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
before, readErr := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
|
||||
nodeID, writeErr := completeCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, task, policy)
|
||||
if writeErr != nil {
|
||||
var appErr *AppError
|
||||
if !errors.Is(writeErr, gorm.ErrRecordNotFound) && !(errors.As(writeErr, &appErr) && (appErr.Status == 400 || appErr.Status == 409)) {
|
||||
return writeErr // Retry persistence, never resubmit the billed generation.
|
||||
}
|
||||
}
|
||||
// A deleted canvas/node must still checkpoint the tool failure. Only
|
||||
// a matched node can have changed and require an atomic canvas delta.
|
||||
if nodeID != "" {
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if err := emitCloudAgentCanvasChange(repo, run.ID, state, run.UserID, state.Request.CanvasID, before.PayloadJSON, "generate_media_complete"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result := map[string]any{"phase": "completion", "taskSubmitted": true, "taskId": task.ID, "nodeId": nodeID, "status": task.Status}
|
||||
var toolErr error
|
||||
if task.Status != model.TaskStatusSucceeded {
|
||||
toolErr = BadAuthRequest("媒体任务" + string(task.Status) + ":" + cloudAgentSafeMediaTaskError(task) + ";请在任务中心查看任务 " + task.ID + ",不会自动重试收费生成")
|
||||
result["summary"] = "媒体任务未成功;结果保留在任务中心"
|
||||
}
|
||||
if writeErr != nil {
|
||||
toolErr = BadAuthRequest("生成结果未能安全回写画布;任务结果保留在任务中心,不会自动重试收费生成")
|
||||
result["summary"] = "未回写画布;任务结果保留在任务中心"
|
||||
}
|
||||
if task.Status == model.TaskStatusSucceeded && writeErr == nil {
|
||||
result["summary"] = "生成结果已回写画布节点"
|
||||
}
|
||||
// Keep the tool diagnostic as the last event for compatibility with
|
||||
// consumers that render the completion result directly. The terminal
|
||||
// run event is emitted first, so a failed completion still has an
|
||||
// explicit run-level terminal signal without hiding the useful detail.
|
||||
if task.Status != model.TaskStatusSucceeded || writeErr != nil {
|
||||
if current.Status != "cancelled" {
|
||||
current.Status = "failed"
|
||||
}
|
||||
state.event(run.ID, "run_failed", map[string]any{"text": "媒体任务已提交,但结果处理失败;任务不会自动重试", "taskId": task.ID})
|
||||
}
|
||||
cloudAgentToolResult(run.ID, state, call, result, toolErr)
|
||||
state.MediaTaskID = ""
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
}
|
||||
req, plan, err := s.prepareCloudAgentMedia(run, state, call)
|
||||
if err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
return s.enqueueCloudAgentTask(run, state, req, plan)
|
||||
}
|
||||
|
||||
func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, reason string) error {
|
||||
if decision != "approve" && decision != "reject" {
|
||||
return BadAuthRequest("无效审批决定")
|
||||
}
|
||||
if len(reason) > 2000 {
|
||||
return BadAuthRequest("审批理由过长")
|
||||
}
|
||||
if _, _, err := s.cloudAgentTask(userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
run, err := s.repo.CloudAgent(userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, ok := state.Decisions[approvalID]; ok {
|
||||
if previous == decision {
|
||||
return nil
|
||||
}
|
||||
return creationConflict("该审批已有不同决定")
|
||||
}
|
||||
if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID {
|
||||
return creationConflict("审批不存在或已过期")
|
||||
}
|
||||
return s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
state.Approval.Decision = decision
|
||||
state.Approval.Reason = reason
|
||||
state.Decisions[approvalID] = decision
|
||||
current.Status = "running"
|
||||
state.event(id, "approval_decided", map[string]any{"approvalId": approvalID, "decision": decision})
|
||||
return cloudAgentSave(current, &state)
|
||||
})
|
||||
}
|
||||
func (s *Service) CancelCloudAgent(ctx context.Context, userID, id string) error {
|
||||
// Cancellation is a control-plane operation. It must remain available even
|
||||
// when the user-facing runtime blob is damaged, so authenticate/authorize
|
||||
// from the task row first instead of calling CloudAgentRun up front.
|
||||
task, err := s.repo.TaskForUser(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return kernel.NotFound("Agent 运行不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if task.Operation != cloudAgentOperation {
|
||||
return kernel.NotFound("Agent 运行不存在")
|
||||
}
|
||||
run, err := s.repo.CloudAgent(userID, id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Legacy root tasks may not have an execution row yet. The normal read
|
||||
// path validates the signed/deterministic task identity before creating it.
|
||||
if _, err = s.CloudAgentRun(userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
run, err = s.repo.CloudAgent(userID, id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) {
|
||||
return nil
|
||||
}
|
||||
if run.Status != "failed" {
|
||||
// Persist intent independently of the transcript. Retrying also repairs
|
||||
// legacy cancelled rows that crashed before cancelling their children.
|
||||
state, decodeErr := cloudAgentDecode(run)
|
||||
err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
current.Status = "cancelled"
|
||||
current.CleanupPending = true
|
||||
if decodeErr == nil {
|
||||
current.CanvasID, current.ActiveTaskID, current.MediaTaskID = state.Request.CanvasID, state.ActiveTaskID, state.MediaTaskID
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
latest, err := s.repo.CloudAgent(userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.finishCloudAgentCleanup(ctx, latest)
|
||||
}
|
||||
Reference in New Issue
Block a user