feat(deploy): 画布 Agent - 独立 yingce-agent 容器并修复运行中断

生产会话不再因 stdout 管道提前关闭被误判失败。设置 YINGCE_AGENT_URL 后只走独立容器,失败不回退到内嵌进程。
This commit is contained in:
ddcat666 committed 2026-09-29 21:41:36 +08:00
1 parent 3fb363aad2
commit cb146d27a1
25 files changed
+821 -101

No files matched your search

+26 -12
View File
@@ -619,7 +619,9 @@ func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
}
}
func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) {
// 界面承诺“图片、视频始终先创建草稿,再经独立审批才提交”:auto 也必须先审批,
// 审批前不建任务、不扣费;批准后恰好提交一次。
func TestCloudAgentAutoMediaRequiresApprovalBeforeSubmit(t *testing.T) {
s, db, a := agentMediaFixture(t)
// Omitted size and duration should be filled by the selected model's catalog
// defaults during admission instead of producing a repair turn.
@@ -638,16 +640,28 @@ func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if run.Status == "waiting_approval" || state.Approval != nil {
t.Fatalf("auto mode unexpectedly requested approval: status=%s approval=%+v", run.Status, state.Approval)
if run.Status != "waiting_approval" || state.Approval == nil {
t.Fatalf("auto mode submitted media without approval: status=%s", run.Status)
}
var pendingTasks, pendingOrders int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&pendingTasks)
db.Model(&model.BillingOrder{}).Count(&pendingOrders)
if pendingTasks != 0 || pendingOrders != ordersBefore {
t.Fatalf("charged/submitted before approval: tasks=%d orders=%d before=%d", pendingTasks, pendingOrders, ordersBefore)
}
if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, "approve", ""); err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, err = cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
if state.MediaTaskID == "" {
t.Fatalf("auto mode did not submit a media task: %+v", state.Events)
}
for _, event := range state.Events {
if event.Type == "approval_requested" {
t.Fatal("auto mode emitted an approval event")
}
t.Fatalf("approved auto media was not submitted: status=%s", run.Status)
}
task, err := s.repo.TaskForUser("user", state.MediaTaskID)
if err != nil {
@@ -665,7 +679,7 @@ func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) {
nodes, _ := creationObjects(doc["nodes"])
meta, _ := nodes[a.NodeID]["metadata"].(map[string]any)
if meta["status"] != "loading" || meta["taskId"] != task.ID || meta["agentDraftRunId"] != nil {
t.Fatalf("auto submission did not finalize the draft: %+v", meta)
t.Fatalf("approved submission did not finalize the draft: %+v", meta)
}
var tasks, orders int64
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
@@ -675,7 +689,7 @@ func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) {
t.Fatal(err)
}
if tasks != 1 || orders != ordersBefore+1 {
t.Fatalf("auto submission created unexpected records: tasks=%d orders=%d before=%d", tasks, orders, ordersBefore)
t.Fatalf("approved submission created unexpected records: tasks=%d orders=%d before=%d", tasks, orders, ordersBefore)
}
// Re-entering a submitted call is idempotent and must not reserve/submit a
// second generation.
@@ -686,7 +700,7 @@ func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) {
t.Fatal(err)
}
if tasks != 1 {
t.Fatalf("re-entering auto media duplicated task: %d", tasks)
t.Fatalf("re-entering approved media duplicated task: %d", tasks)
}
// Admission failures are still non-billed tool failures and do not create an
@@ -231,9 +231,11 @@ func (s *Service) recoverCloudAgentPiRunners() {
if run.Status == "waiting_approval" || run.Status == "waiting_user" {
continue
}
// 批准后的媒体任务仍在生成:由 finishApprovedCloudAgentMedia 等结果回写后再恢复运行。
// 这时提前启动运行时,会在媒体任务未结束时再入队模型步骤,形成两个活动任务。
if s.cloudAgentAwaitingMedia(run.UserID, run.ID) {
// 批准后的媒体任务仍在生成。进程重启后内存 waiter 已经消失,这里按
// 持久化的任务 ID 重建;不能同时再启动一轮模型步骤。
state, decodeErr := cloudAgentDecode(&run)
if decodeErr == nil && state.MediaTaskID != "" {
s.startApprovedCloudAgentMediaWaiter(run.UserID, run.ID, state.MediaTaskID)
continue
}
s.startCloudAgentPi(run.UserID, run.ID)
@@ -245,18 +247,6 @@ func (s *Service) recoverCloudAgentPiRunners() {
}
}
// cloudAgentAwaitingMedia reports whether the run is waiting on an approved
// media task. The runtime must not start (and schedule a model step) until that
// task has been written back; one run owns at most one active task.
func (s *Service) cloudAgentAwaitingMedia(userID, runID string) bool {
run, err := s.repo.CloudAgent(userID, runID)
if err != nil {
return false
}
state, err := cloudAgentDecode(run)
return err == nil && state.MediaTaskID != ""
}
// runCloudAgentPiSession 核心会话管理
func (s *Service) runCloudAgentPiSession(ctx context.Context, userID, runID string) error {
// 1. 加载运行状态
@@ -1085,6 +1075,32 @@ func (s *Service) cloudAgentPiTool(ctx context.Context, userID, runID string, pa
if call.Function.Name == "" {
return nil, fmt.Errorf("tool call is missing a name")
}
// 工具白名单校验:拒绝未声明的工具调用
run, err := s.repo.CloudAgent(userID, runID)
if err != nil {
return nil, err
}
state, err := cloudAgentDecode(run)
if err != nil {
return nil, err
}
allowedTools := make(map[string]bool)
for _, tool := range state.Canonical.Tools {
if fn, ok := tool["function"].(map[string]interface{}); ok {
name := stringValue(fn["name"])
if name != "" {
allowedTools[name] = true
}
}
}
if !allowedTools[call.Function.Name] {
log.Printf("[Agent] rejected undeclared tool call: %s in run %s", call.Function.Name, runID)
return nil, fmt.Errorf("未声明的工具: %s", call.Function.Name)
}
return s.executeCloudAgentRuntimeTool(ctx, userID, runID, call)
}
@@ -2,8 +2,11 @@ package app
import (
"context"
"os"
"strings"
agentruntime "infinite-canvas/backend/internal/agent/runtime"
"infinite-canvas/backend/internal/agent/yingceagent"
)
// These aliases keep the app coordinator's request shape stable while the
@@ -37,7 +40,7 @@ type cloudAgentPiProcessRequest struct {
}
func runCloudAgentPi(ctx context.Context, request cloudAgentPiProcessRequest, bridge cloudAgentPiBridge) error {
return agentruntime.Run(ctx, agentruntime.ProcessRequest{
runtimeRequest := agentruntime.ProcessRequest{
BridgeURL: request.BridgeURL, BridgeToken: request.BridgeToken,
SessionFile: request.SessionFile, SessionJSONL: request.SessionJSONL, SessionDir: request.SessionDir,
SessionID: request.SessionId, UserID: request.UserId, CanvasID: request.CanvasId, RunID: request.RunId,
@@ -45,5 +48,9 @@ func runCloudAgentPi(ctx context.Context, request cloudAgentPiProcessRequest, br
SkillPaths: request.SkillPaths, EnabledSkills: request.EnabledSkills, Profile: request.Profile,
Memory: request.Memory, Canvas: request.Canvas, Features: request.Features, Tools: request.Tools,
Compaction: request.Compaction, Permissions: request.Permissions, Model: request.Model,
}, agentruntime.Bridge(bridge))
}
if endpoint := strings.TrimSpace(os.Getenv("YINGCE_AGENT_URL")); endpoint != "" {
return yingceagent.Run(ctx, endpoint, os.Getenv("YINGCE_AGENT_TOKEN"), os.Getenv("YINGCE_AGENT_BRIDGE_HOST"), runtimeRequest, agentruntime.Bridge(bridge))
}
return agentruntime.Run(ctx, runtimeRequest, agentruntime.Bridge(bridge))
}
+2 -1
View File
@@ -1712,6 +1712,7 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
}
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
@@ -1753,7 +1754,7 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
}
if plan != nil && state.Request.PermissionMode == "auto" {
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.
@@ -26,6 +26,20 @@ func TestCloudAgentModelStepWaitsForPendingMediaTask(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// 媒体生成在所有模式下都要先审批;批准后才提交媒体任务。
if state.Approval == nil {
t.Fatalf("media did not request approval (status=%s)", run.Status)
}
s.disablePiRuntime = true
if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, "approve", ""); err != nil {
t.Fatal(err)
}
if run, err = s.repo.CloudAgent("user", run.ID); err != nil {
t.Fatal(err)
}
if state, err = cloudAgentDecode(run); err != nil {
t.Fatal(err)
}
if state.MediaTaskID == "" {
t.Fatalf("fixture did not submit a media task (status=%s failure=%q)", run.Status, run.FailureMessage)
}
+2 -2
View File
@@ -536,7 +536,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
"topic", "category", "situation")
}
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;request_approval 进入界面独立审批,auto 由服务端准入成功后直接提交,不会再次弹出模型生成审批。", map[string]any{
add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;所有权限模式都会先创建草稿并进入界面独立审批,用户批准后才提交生成任务。", map[string]any{
"prompt": str("需要拆分的对象与透明背景要求"), "logicalModelId": str("selection.logicalModelId"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"),
"quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")},
}, "prompt", "nodeId", "title", "referenceNodeIds")
@@ -610,7 +610,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
}, "snapshotHash")
}
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
add("generate_media", "提交媒体生成:先准备草稿和引用;request_approval 需要用户确认,auto 在服务端完成模型、能力、价格、预算和资源校验后直接提交,不会再次弹出模型生成审批。auto 只在准入成功后提交。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{
add("generate_media", "提交媒体生成:先准备草稿和引用;所有权限模式都会先经服务端完成模型、能力、价格、预算和资源校验,再进入界面独立审批,用户批准后才提交生成任务。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{
"mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词;引用素材时在对应描述中使用 @图片1、@视频1、@音频1,各类型按 referenceNodeIds 中出现顺序独立编号,文本来源不占媒体编号。服务端会为遗漏的已选素材补齐引用标签,不推断素材用途"),
"logicalModelId": str("selection.logicalModelId;与channelId/channelModelKey互斥"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"),
"durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str("模型支持的画幅,例如9:16"), "quality": str("目录支持的分辨率或质量"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"},