Stabilize OpenClaw artifact finality

This commit is contained in:
Haitao Pan 2026-06-08 10:49:07 +08:00
parent 169ec72783
commit 8fc9a614f2
4 changed files with 211 additions and 7 deletions

View File

@ -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
}

View File

@ -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,

View File

@ -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()

View File

@ -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 {