Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_runtime.go
jiabx e69bea15ef fix(agent): 画布助手 - 工具参数超限改为纠偏回执,不再判死整个运行 (#701)
* fix(agent): 画布助手 - 工具参数超限改为纠偏回执,不再判死整个运行

* fix(agent): 画布助手 - 桥接优先回放参数回执,结构类调用问题整步拒绝

---------

Co-authored-by: Future <jiaboxuan1205@163.com>
2026-10-08 12:15:22 +08:00

300 lines
17 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.
package app
import (
"encoding/json"
"errors"
"fmt"
"time"
"infinite-canvas/backend/internal/agentcontext"
"infinite-canvas/backend/internal/model"
)
// A deterministic checkpoint failure must not be retried forever like a transient DB error.
var errCloudAgentCheckpoint = errors.New("invalid Agent checkpoint")
// cloudAgentCheckpointError keeps the save stage attached to a deterministic
// checkpoint failure. Previously every failure was flattened to the same
// sentinel and advanceCloudAgent consequently told users that context had
// overflowed even when the actual problem was a corrupt event or an encoding
// failure.
type cloudAgentCheckpointError struct {
Stage string
Err error
}
func (e *cloudAgentCheckpointError) Error() string {
if e == nil {
return errCloudAgentCheckpoint.Error()
}
if e.Stage == "" {
return fmt.Sprintf("%v: %v", errCloudAgentCheckpoint, e.Err)
}
return fmt.Sprintf("%v (%s): %v", errCloudAgentCheckpoint, e.Stage, e.Err)
}
func (e *cloudAgentCheckpointError) Unwrap() error {
if e == nil {
return errCloudAgentCheckpoint
}
return e.Err
}
func (e *cloudAgentCheckpointError) Is(target error) bool {
return target == errCloudAgentCheckpoint || (e != nil && errors.Is(e.Err, target))
}
func cloudAgentCheckpointFailure(stage string, err error) error {
if err == nil {
err = errors.New("unknown checkpoint failure")
}
return &cloudAgentCheckpointError{Stage: stage, Err: err}
}
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"`
ItemID string `json:"item_id,omitempty"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type cloudAgentCachedToolResult struct {
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ArgumentError bool `json:"argumentError,omitempty"`
// ReplayCount is diagnostic only. Replaying a cached read is harmless and
// must not fail the run; the per-run real-read budget limits cache misses.
ReplayCount int `json:"replayCount,omitempty"`
}
type cloudAgentReadLoopError struct {
ToolName string
Count int
Budget bool
ReasonCode string
}
func (e *cloudAgentReadLoopError) reasonCode() string {
if e == nil || e.ReasonCode == "" {
if e != nil && e.Budget {
return "read_budget_exceeded"
}
return "repeated_read_guard"
}
return e.ReasonCode
}
func (e *cloudAgentReadLoopError) Error() string {
if e == nil {
return "Agent 重复读取护栏已触发"
}
if e.Budget {
return fmt.Sprintf("Agent 本轮只读工具调用已达到安全上限(%d 次),本轮已停止以避免继续消耗模型调用;请使用已有结果继续,不要继续读取", e.Count)
}
return fmt.Sprintf("Agent 连续重复读取同一份%s结果,本轮已停止以避免继续消耗模型调用;请使用已有结果继续,不要再次读取", e.ToolName)
}
type cloudAgentApproval struct {
Prepared *cloudAgentPreparedMedia `json:"prepared,omitempty"`
ModelName string `json:"modelName,omitempty"`
ID string `json:"approvalId"`
Call cloudAgentCall `json:"call"`
CallHash string `json:"callHash,omitempty"`
Preview cloudAgentApprovalPreview `json:"preview"`
Decision string `json:"decision,omitempty"`
Reason string `json:"reason,omitempty"`
}
type cloudAgentRuntime struct {
RuntimeRunID string `json:"-"`
Request CloudAgentRequest `json:"request"`
Policy cloudAgentPolicySnapshot `json:"policy"`
ParentID string `json:"parentId,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"`
TextHistory []providerTextMessage `json:"textHistory,omitempty"`
Skills []cloudAgentSkill `json:"skills"`
Profile cloudAgentProfileSnapshot `json:"profile"`
ProfileReads map[string]bool `json:"profileReads,omitempty"`
ToolReadResults map[string]cloudAgentCachedToolResult `json:"toolReadResults,omitempty"`
ToolReadReplays map[string]int `json:"toolReadReplays,omitempty"`
readCacheExecution bool `json:"-"`
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"`
ToolRepairs map[string]cloudAgentToolRepair `json:"toolRepairs,omitempty"`
Approval *cloudAgentApproval `json:"approval,omitempty"`
// AutoPreparedMedia is the durable admission checkpoint for permissionMode=auto.
// It prevents a worker restart between dry admission and billed submission from
// re-running admission or creating the draft node a second time.
AutoPreparedMedia *cloudAgentPreparedMedia `json:"autoPreparedMedia,omitempty"`
AutoPreparedCallHash string `json:"autoPreparedCallHash,omitempty"`
Decisions map[string]string `json:"decisions"`
DecisionSettings map[string]string `json:"decisionSettings,omitempty"`
DecisionPreparedHashes map[string]string `json:"decisionPreparedHashes,omitempty"`
ActionNudged bool `json:"actionNudged,omitempty"`
EmptyOutputNudged int `json:"emptyOutputNudged,omitempty"`
InvalidArgumentSteps int `json:"invalidArgumentSteps,omitempty"`
StepSnapshotHash string `json:"stepSnapshotHash,omitempty"`
StoryboardTaskID string `json:"storyboardTaskId,omitempty"`
Plan []cloudAgentPlanItem `json:"plan,omitempty"`
ConfirmationRounds int `json:"confirmationRounds,omitempty"`
ConfirmationFingerprints []string `json:"confirmationFingerprints,omitempty"`
PendingConfirmationFingerprint string `json:"pendingConfirmationFingerprint,omitempty"`
PendingInterjections []cloudAgentInterjection `json:"pendingInterjections,omitempty"`
PiResumePrompt string `json:"piResumePrompt,omitempty"`
TransientReferences map[string]cloudAgentTransientReference `json:"transientReferences,omitempty"`
InterjectionIDs []string `json:"interjectionIds,omitempty"`
// 已送达插话正文的 SHA-256 指纹(有界)。运行时投影回写 canonical 时不带
// 来源标记,模型步按指纹补回 user_interjection,见 cloud_agent_interjection_pi.go。
DeliveredInterjectionDigests []string `json:"deliveredInterjectionDigests,omitempty"`
Events []CloudAgentEvent `json:"events"`
// PiAssistantResponses counts successful assistant message_end events from
// the Pi runtime. Completion must not be inferred from a clean Node exit:
// a provider/session error can otherwise be reported as a successful run.
PiAssistantResponses int `json:"piAssistantResponses,omitempty"`
LessonEligibleToolSuccesses int `json:"lessonEligibleToolSuccesses,omitempty"`
RememberLessonSuccesses int `json:"rememberLessonSuccesses,omitempty"`
IsGenerating bool `json:"isGenerating,omitempty"`
LastError string `json:"lastError,omitempty"`
// EmptyOutputEscalated 记录"空输出已经升级重试过几次"(关思考 + 放大输出预算)。
EmptyOutputEscalated int `json:"emptyOutputEscalated,omitempty"`
// StepTimeoutEscalated 记录"单步墙钟到点后已经关思考重试过几次"。
StepTimeoutEscalated int `json:"stepTimeoutEscalated,omitempty"`
// ForceThinkingOff 让本步请求强制关闭上游思考:思考模型偶发把整个输出预算花在推理上,
// 结果正文与工具调用皆空(实测 output_tokens 正好等于 maxOutputTokens)。
ForceThinkingOff bool `json:"forceThinkingOff,omitempty"`
// BoostStepOutputBudget 让本步请求使用放大后的输出预算(配合关思考重试)。
BoostStepOutputBudget bool `json:"boostStepOutputBudget,omitempty"`
// StepLimits 是本步实际生效的执行边界(管理员策略解析结果),只用于构造请求:
// 不进状态 JSON——每次推进都按当时的策略重新解析,改配置无需重发本轮。
StepLimits cloudAgentStepLimits `json:"-"`
// ImageInspectCounts 记录本轮内每张图被查看的次数,用于"同一张图不要反复看"的护栏。
ImageInspectCounts map[string]int `json:"imageInspectCounts,omitempty"`
// ImageInspectionReads 以“节点 + 资源 + 画布 revision”为 key,避免同一张图在
// 同一版本的画布里反复触发视觉输入。画布内容变化后 key 自然变化,允许重新识别。
ImageInspectionReads map[string]int `json:"imageInspectionReads,omitempty"`
// ImageInspectCalls 记录本轮所有图片识别工具调用次数(包括只回执文字的重复调用)。
// 它与 ImageInspectCounts 一起进检查点,防止模型通过 refresh 或切换节点绕过总预算。
ImageInspectCalls int `json:"imageInspectCalls,omitempty"`
// ReadToolCalls 记录本轮会读取运行时只读快照的工具调用次数。除了同参缓存护栏,
// 还需要一个跨参数的总上限,防止模型通过不断变化 offset/nodeIds 绕过重复读取保护。
ReadToolCalls int `json:"readToolCalls,omitempty"`
// PendingImageInspections 暂存"本批还有工具结果没入历史"的看图结果,等整批 tool
// 结果都入历史后合并成一条 user 图片消息(见 cloudAgentFlushPendingImages)。
//
// 它必须进检查点,不能标 `json:"-"`:一次 advanceCloudAgent 只执行一个工具调用
// (advanceCloudAgentTool 执行完就 return,下一批调用走下一次转移),而每次转移都
// 从 StateJSON 重新解码(cloudAgentDecode)。进程内字段在下一个调用到来时必然为空,
// 缓冲就白缓冲了。载荷只有回执与签名链接,几十字节级。
PendingImageInspections []cloudAgentImageInspection `json:"pendingImageInspections,omitempty"`
// 以下三个字段属于"超预算时压缩成检查点后继续本轮"(见 cloud_agent_context_compaction.go)。
// ContextCompactionCount 是本轮已经压过几次:压完仍然超阈值时不能无限暂停。
ContextCompactionCount int `json:"contextCompactionCount,omitempty"`
// ContextCheckpoint 是最近一次落盘的结构化检查点;ContextCompaction 是"正在压缩"的状态面
// (requested 已请求 / running 压缩任务已发出),Resume 表示压完继续本轮而不是收尾结束。
ContextCheckpoint *agentcontext.Checkpoint `json:"contextCheckpoint,omitempty"`
ContextCompaction *cloudAgentContextCompaction `json:"contextCompaction,omitempty"`
// HistoryIncludesCurrent 说明 TextHistory 是压缩瞬间的 canonical 快照:
// 续轮只补快照之后的新插话与最终回复,不能重复已压缩的用户要求。
HistoryIncludesCurrent bool `json:"historyIncludesCurrent,omitempty"`
// EventSeqBase 是本次载入的事件窗口之前已入库的条数,不变量是
// events[i].Seq == EventSeqBase + i + 1。事件全量在 cloud_agent_event_records,
// 内存只保留最近一窗(repository.CloudAgentJournalWindow),因此它是"窗口在整条
// 日志里的偏移",而不是累计条数。只在内存里有效,不进检查点(检查点里的事件为空)。
EventSeqBase int `json:"-"`
}
type cloudAgentTransientReference struct {
ID string `json:"id"`
Name string `json:"name"`
MIMEType string `json:"mimeType"`
ResourceID string `json:"resourceId"`
ExpiresAt time.Time `json:"expiresAt"`
}
func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgentState) error {
var input struct {
TextHistory []providerTextMessage `json:"textHistory"`
Config map[string]any `json:"config"`
Requests struct {
Canonical canonicalAgentRequest `json:"canonical"`
} `json:"agentRequests"`
}
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return err
}
canonical := input.Requests.Canonical
canonical.SystemPrompt = stripCloudAgentPlanBlock(canonical.SystemPrompt)
canonical.Messages = stripCloudAgentRuntimeContext(canonical.Messages)
limits, err := s.cloudAgentStepLimits()
if err != nil {
return err
}
carrier := task.Status == model.TaskStatusTextReplay
// The Pi root is a non-billable control-plane carrier, but it is still the
// durable task identity for this run. Keep it in TaskIDs even though it is
// not an active model task; validation and recovery use TaskIDs as the run's
// immutable task-history anchor. Leaving this nil makes every new Pi run
// fail its first checkpoint with "Agent runtime task history is invalid".
state := cloudAgentRuntime{Request: initial.Request, Policy: initial.Policy, ParentID: initial.ParentID, Fingerprint: initial.Fingerprint, CreativeAnchor: initial.CreativeAnchor, TextHistory: input.TextHistory, Skills: initial.Skills, Profile: initial.Profile, Canonical: canonical, ActiveTaskID: "", TaskIDs: []string{task.ID}, Step: 0, Decisions: map[string]string{}, Plan: initial.Plan, ConfirmationRounds: initial.ConfirmationRounds, ConfirmationFingerprints: append([]string(nil), initial.ConfirmationFingerprints...), PendingConfirmationFingerprint: "", Events: []CloudAgentEvent{}, StepLimits: limits}
if !carrier {
state.ActiveTaskID = task.ID
state.Step = 1
}
if len(initial.Skills) > 0 {
// skillIds makes the enablement auditable: usage telemetry can attribute a
// run to the skills it actually loaded instead of only counting the total.
skillIDs := make([]string, 0, len(initial.Skills))
for _, skill := range initial.Skills {
skillIDs = append(skillIDs, skill.ID)
}
state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "skillIds": skillIDs, "text": fmt.Sprintf("已启用 %d 个技能,正文将按需读取", len(initial.Skills))})
}
if len(state.Plan) > 0 {
// 继承的待办清单必须挂到本轮:前端按 plan-<runId> 展示清单,不发这条事件时
// 界面会一直停在上一轮(已结束)的清单上,显示"未完成项已停止"。
state.event(task.ID, "plan_updated", map[string]any{"items": state.Plan, "pendingTitles": cloudAgentPendingPlanItems(state.Plan), "inherited": true})
}
if carrier {
// The carrier is deliberately not a model call. Pi will create the first
// governed cloud_agent_step through the model bridge below.
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)
}
// 非 Pi 的旧入口仍保留运行状态初始化,但上下文占用统一由 Pi SDK
// 在实际模型调用前上报;这里不再生成 Go 侧估算读数。
run := &model.CloudAgentExecution{ID: task.ID, UserID: task.UserID, Status: "running", Revision: 1, CreatedAt: task.CreatedAt, UpdatedAt: time.Now()}
if err := cloudAgentSave(run, &state); err != nil {
return err
}
return s.repo.EnsureCloudAgent(run)
}
func (state *cloudAgentRuntime) event(id, kind string, payload map[string]any) {
// 序号接在"已载入窗口 + 窗口之前已入库条数"之后,与 EventSeqBase 的不变量一致。
seq := state.EventSeqBase + 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()})
}