feat(deploy): 画布 Agent - 独立 yingce-agent 容器并修复运行中断
生产会话不再因 stdout 管道提前关闭被误判失败。设置 YINGCE_AGENT_URL 后只走独立容器,失败不回退到内嵌进程。
This commit is contained in:
1 parent
3fb363aad2
commit
cb146d27a1
25 files changed
+821
-101
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": "是否生成音频,仅视频可用"},
|
||||
|
||||
Reference in new issue
Block a user