From 8fc9a614f26cbad0176f2bfee67fe77c67a8d2ca Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 10:49:07 +0800 Subject: [PATCH] Stabilize OpenClaw artifact finality --- internal/acp/openclaw_async_tasks.go | 8 ++ internal/acp/orchestrator.go | 47 +++++++++++ internal/acp/routing_test.go | 50 ++++++++++++ internal/acp/rpc_handler.go | 113 +++++++++++++++++++++++++-- 4 files changed, 211 insertions(+), 7 deletions(-) diff --git a/internal/acp/openclaw_async_tasks.go b/internal/acp/openclaw_async_tasks.go index a8d7fac..2b4df59 100644 --- a/internal/acp/openclaw_async_tasks.go +++ b/internal/acp/openclaw_async_tasks.go @@ -29,6 +29,8 @@ type OpenClawTaskRecord struct { ProgressMessage string PreparedArtifact *openClawPreparedArtifactScope RequiresArtifactExport bool + ExpectedArtifactDirs []string + RequiredArtifactExts []string ResolvedModel string ResolvedSkills []string } @@ -93,6 +95,12 @@ func openClawRunningTaskResult(record *OpenClawTaskRecord) map[string]any { if record.PreparedArtifact != nil { applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact) } + if len(record.ExpectedArtifactDirs) > 0 { + result["expectedArtifactDirs"] = append([]string(nil), record.ExpectedArtifactDirs...) + } + if len(record.RequiredArtifactExts) > 0 { + result["requiredArtifactExtensions"] = append([]string(nil), record.RequiredArtifactExts...) + } return result } diff --git a/internal/acp/orchestrator.go b/internal/acp/orchestrator.go index b5b8bc8..6dcdbf5 100644 --- a/internal/acp/orchestrator.go +++ b/internal/acp/orchestrator.go @@ -398,6 +398,8 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask( ProgressMessage: "OpenClaw task accepted", PreparedArtifact: preparedArtifact, RequiresArtifactExport: artifactContract.RequiresArtifactExport, + ExpectedArtifactDirs: append([]string(nil), artifactContract.ExpectedArtifactDirs...), + RequiredArtifactExts: append([]string(nil), artifactContract.RequiredArtifactExts...), ResolvedModel: routing.Model, ResolvedSkills: append([]string(nil), routing.Skills...), } @@ -621,6 +623,9 @@ func openClawSessionPrepareParams(params map[string]any, openClawSessionKey stri if artifactContract.RequiresArtifactExport { result["requiresArtifactExport"] = true } + if len(artifactContract.RequiredArtifactExts) > 0 { + result["requiredArtifactExtensions"] = append([]string(nil), artifactContract.RequiredArtifactExts...) + } if workspaceDir := openClawArtifactWorkspaceDir(params); workspaceDir != "" { result["workspaceDir"] = workspaceDir } @@ -778,6 +783,7 @@ type openClawArtifactContract struct { ComplexLongChain bool RequiresArtifactExport bool ExpectedArtifactDirs []string + RequiredArtifactExts []string SourceMessage string } @@ -793,11 +799,19 @@ func openClawArtifactContractForParams(params map[string]any, chatParams map[str expectedDirs := normalizeOpenClawDirList(shared.ListArg(contract, "expectedArtifactDirs")) requiresExport := parseBool(contract["requiresExportBeforeFinalResponse"]) || len(expectedDirs) > 0 complex := taskLoadClass == "complex_long_chain_task" || isOpenClawLongArtifactTask(lowerMessage) + requiredExts := normalizeOpenClawArtifactExtList(shared.ListArg(metadata, "requiredArtifactExtensions")) + if len(requiredExts) == 0 { + requiredExts = normalizeOpenClawArtifactExtList(shared.ListArg(metadata, "expectedArtifactExtensions")) + } + if len(requiredExts) == 0 { + requiredExts = inferOpenClawRequiredArtifactExts(lowerMessage) + } return openClawArtifactContract{ TaskLoadClass: taskLoadClass, ComplexLongChain: complex, RequiresArtifactExport: requiresExport, ExpectedArtifactDirs: expectedDirs, + RequiredArtifactExts: requiredExts, SourceMessage: message, } } @@ -819,6 +833,39 @@ func normalizeOpenClawDirList(values []any) []string { return result } +func normalizeOpenClawArtifactExtList(values []any) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + ext := strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) + ext = strings.TrimPrefix(ext, ".") + if ext == "" || strings.Contains(ext, "/") || strings.Contains(ext, "\\") || seen[ext] { + continue + } + seen[ext] = true + result = append(result, ext) + } + return result +} + +func inferOpenClawRequiredArtifactExts(lowerMessage string) []string { + switch { + case openClawMessageContainsAny(lowerMessage, []string{"pdf", "输出 pdf", "生成 pdf"}): + return []string{"pdf"} + case openClawMessageContainsAny(lowerMessage, []string{"视频", "video", "mp4", "渲染"}): + return []string{"mp4"} + case openClawMessageContainsAny(lowerMessage, []string{"图片", "图像", "png", "jpg", "jpeg", "webp", "生成图"}): + return []string{"png", "jpg", "jpeg", "webp"} + case openClawMessageContainsAny(lowerMessage, []string{"markdown", "md文件", ".md", "文案", "资讯"}): + return []string{"md"} + default: + return nil + } +} + func openClawChatSendParams( params map[string]any, turnID string, diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index d461437..a83c3c4 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -1925,6 +1925,56 @@ func TestExecuteSessionMessageGatewayDoesNotRewriteClaimedArtifactsWithoutGatewa } } +func TestExecuteSessionMessageGatewayVerifiesClaimedArtifactsWhenExportRequired(t *testing.T) { + gateway := newAcpFakeOpenClawGateway(t) + defer gateway.Close() + + t.Setenv("GATEWAY_RPC_URL", gateway.URL()) + t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token") + + server := NewServer() + response, rpcErr := server.executeSessionTask(task{ + req: shared.RPCRequest{ + Method: "session.message", + Params: map[string]any{ + "sessionId": "session-openclaw-claimed-required-artifact", + "threadId": "thread-openclaw-claimed-required-artifact", + "taskPrompt": "fallback artifact hallucinate-files", + "workingDirectory": t.TempDir(), + "metadata": map[string]any{ + "xworkmateTaskArtifactContract": map[string]any{ + "requiresExportBeforeFinalResponse": true, + "expectedArtifactDirs": []any{"exports/"}, + }, + }, + "routing": map[string]any{ + "routingMode": "explicit", + "explicitExecutionTarget": "gateway", + "preferredGatewayProviderId": "openclaw", + }, + }, + }, + }) + if rpcErr != nil { + t.Fatalf("expected gateway response, got rpc error: %#v", rpcErr) + } + if got := response["status"]; got != string(TaskStateRunning) { + t.Fatalf("expected required but unverified artifact claim to stay syncing, got %#v", response) + } + if artifacts := extractArtifactPayloads(response, shared.StringArg(response, "remoteWorkingDirectory", "")); len(artifacts) != 0 { + t.Fatalf("expected bridge to remove unverified native artifact claims, got %#v", artifacts) + } + if got := shared.StringArg(shared.AsMap(response["progress"]), "stage", ""); got != "syncing-artifacts" { + t.Fatalf("expected syncing-artifacts progress, got %#v", response) + } + if gateway.ArtifactExportCount() != 1 { + t.Fatalf("expected Bridge artifact export verification, got %d", gateway.ArtifactExportCount()) + } + if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get", "xworkmate.artifacts.export"}) { + t.Fatalf("expected connect, prepare, chat.send, task lookup, then artifact export, got %#v", got) + } +} + func TestExecuteSessionMessageGatewayExportsArtifactsWithoutPromptHeuristic(t *testing.T) { gateway := newAcpFakeOpenClawGateway(t) defer gateway.Close() diff --git a/internal/acp/rpc_handler.go b/internal/acp/rpc_handler.go index 06f4b9b..60e690c 100644 --- a/internal/acp/rpc_handler.go +++ b/internal/acp/rpc_handler.go @@ -169,6 +169,12 @@ func (s *Server) taskGetParamsWithSessionScope(params map[string]any) map[string if _, ok := next["requiresArtifactExport"]; !ok && sess.openClaw != nil && sess.openClaw.RequiresArtifactExport { next["requiresArtifactExport"] = true } + if _, ok := next["expectedArtifactDirs"]; !ok && sess.openClaw != nil && len(sess.openClaw.ExpectedArtifactDirs) > 0 { + next["expectedArtifactDirs"] = append([]string(nil), sess.openClaw.ExpectedArtifactDirs...) + } + if _, ok := next["requiredArtifactExtensions"]; !ok && sess.openClaw != nil && len(sess.openClaw.RequiredArtifactExts) > 0 { + next["requiredArtifactExtensions"] = append([]string(nil), sess.openClaw.RequiredArtifactExts...) + } if strings.TrimSpace(shared.StringArg(next, "openclawSessionKey", "")) == "" { next["openclawSessionKey"] = sess.task.SessionKey } @@ -197,9 +203,6 @@ func (s *Server) mergeOpenClawTaskGetArtifactExport(payload map[string]any, para return } remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", "")) - if len(extractArtifactPayloads(payload, remoteWorkingDirectory)) > 0 { - return - } sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey") if sessionKey == "" { sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "")) @@ -235,10 +238,15 @@ func (s *Server) mergeOpenClawTaskGetArtifactExport(payload map[string]any, para "maxInlineBytes": 0, "includeContent": false, } - if expectedDirs := shared.ListArg(params, "expectedArtifactDirs"); len(expectedDirs) > 0 { + if expectedDirs := openClawTaskGetExpectedArtifactDirs(params, payload); len(expectedDirs) > 0 { exportParams["expectedArtifactDirs"] = expectedDirs } - mergeOpenClawArtifactPayload(payload, s.orchestrator.openClawArtifactExportRequest(gatewayProvider, exportParams, notify)) + exportPayload := s.orchestrator.openClawArtifactExportRequest(gatewayProvider, exportParams, notify) + if openClawArtifactExportPayloadAuthoritative(exportPayload) { + replaceOpenClawArtifactPayload(payload, exportPayload) + } else { + mergeOpenClawArtifactPayload(payload, exportPayload) + } applyOpenClawPreparedArtifactToResult(payload, prepared) s.decorateOpenClawArtifactDownloadURLs(payload, sessionKey, runID) stripOpenClawArtifactInlineContent(payload) @@ -260,7 +268,9 @@ func normalizeOpenClawTaskGetResult(params map[string]any, payload map[string]an return payload } remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", "")) - if len(extractArtifactPayloads(payload, remoteWorkingDirectory)) > 0 { + artifacts := extractArtifactPayloads(payload, remoteWorkingDirectory) + requiredExts := openClawTaskGetRequiredArtifactExtensions(params, payload) + if len(artifacts) > 0 && openClawArtifactsSatisfyRequiredExtensions(artifacts, requiredExts) { return payload } status := strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", ""))) @@ -296,6 +306,9 @@ func normalizeOpenClawTaskGetResult(params map[string]any, payload map[string]an } payload["artifactScope"] = artifactScope payload["artifactDirectory"] = artifactDirectory + if len(requiredExts) > 0 { + payload["requiredArtifactExtensions"] = append([]string(nil), requiredExts...) + } if strings.TrimSpace(shared.StringArg(payload, "resolvedGatewayProviderId", "")) == "" { payload["resolvedGatewayProviderId"] = gatewayProvider } @@ -315,7 +328,93 @@ func openClawTaskGetRequiresArtifactExport(params map[string]any, payload map[st return true } return len(shared.ListArg(params, "expectedArtifactDirs")) > 0 || - len(shared.ListArg(payload, "expectedArtifactDirs")) > 0 + len(shared.ListArg(payload, "expectedArtifactDirs")) > 0 || + len(shared.ListArg(params, "requiredArtifactExtensions")) > 0 || + len(shared.ListArg(payload, "requiredArtifactExtensions")) > 0 +} + +func openClawTaskGetExpectedArtifactDirs(params map[string]any, payload map[string]any) []any { + seen := map[string]bool{} + result := []any{} + for _, values := range [][]any{ + shared.ListArg(params, "expectedArtifactDirs"), + shared.ListArg(payload, "expectedArtifactDirs"), + } { + for _, value := range values { + item := strings.TrimSpace(fmt.Sprint(value)) + if item == "" || seen[item] { + continue + } + seen[item] = true + result = append(result, item) + } + } + return result +} + +func openClawArtifactExportPayloadAuthoritative(payload map[string]any) bool { + if len(payload) == 0 { + return false + } + if strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", "")) != "" { + return true + } + if strings.TrimSpace(shared.StringArg(payload, "artifactScope", "")) != "" { + return true + } + _, hasArtifacts := payload["artifacts"] + _, hasFiles := payload["files"] + _, hasAttachments := payload["attachments"] + return hasArtifacts || hasFiles || hasAttachments +} + +func replaceOpenClawArtifactPayload(result map[string]any, source map[string]any) { + if result == nil { + return + } + for _, key := range []string{"artifacts", "files", "attachments"} { + delete(result, key) + } + mergeOpenClawArtifactPayload(result, source) +} + +func openClawTaskGetRequiredArtifactExtensions(params map[string]any, payload map[string]any) []string { + return normalizeOpenClawArtifactExtList(openClawTaskGetMergedList(params, payload, "requiredArtifactExtensions")) +} + +func openClawTaskGetMergedList(params map[string]any, payload map[string]any, key string) []any { + seen := map[string]bool{} + result := []any{} + for _, values := range [][]any{ + shared.ListArg(params, key), + shared.ListArg(payload, key), + } { + for _, value := range values { + item := strings.TrimSpace(fmt.Sprint(value)) + if item == "" || seen[item] { + continue + } + seen[item] = true + result = append(result, item) + } + } + return result +} + +func openClawArtifactsSatisfyRequiredExtensions(artifacts []map[string]any, requiredExts []string) bool { + if len(requiredExts) == 0 { + return true + } + for _, artifact := range artifacts { + relativePath := strings.ToLower(strings.TrimSpace(shared.StringArg(artifact, "relativePath", ""))) + for _, ext := range requiredExts { + normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + if normalized != "" && strings.HasSuffix(relativePath, "."+normalized) { + return true + } + } + } + return false } func (s *Server) handleTaskCancel(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {