fix(openclaw): finalize missing required artifacts

This commit is contained in:
Haitao Pan 2026-06-01 15:15:55 +08:00
parent 0b31ccf461
commit ad5d0ab989
3 changed files with 232 additions and 26 deletions

View File

@ -431,6 +431,27 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
result[openClawArtifactExportAttemptedField] = true
exportedCount := openClawArtifactPayloadCount(result)
logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "export", preparedArtifact != nil, exportedCount > 0, exportedCount == 0)
if missing := missingOpenClawRequiredFinalExtensions(result, artifactContract); len(missing) > 0 {
repairPayload := o.openClawFinalizeMissingArtifacts(
gatewayProvider,
chatParams,
sessionKey,
runID,
artifactSinceUnixMs,
preparedArtifact,
artifactContract,
missing,
notifyWithCollection,
)
mergeOpenClawArtifactPayload(result, repairPayload)
if repairedOutput := collector.output(); repairedOutput != "" {
result["output"] = repairedOutput
result["message"] = repairedOutput
result["summary"] = repairedOutput
}
repairedCount := openClawArtifactPayloadCount(result)
logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "finalize", preparedArtifact != nil, repairedCount > 0, repairedCount == 0)
}
o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), runID)
stripOpenClawArtifactInlineContent(result)
applyOpenClawArtifactContractResult(result, artifactContract)
@ -710,9 +731,36 @@ type openClawArtifactContract struct {
}
var (
openClawDottedExtensionPattern = regexp.MustCompile(`(?i)\.([a-z0-9]{2,5})\b`)
openClawFormatTokenPattern = regexp.MustCompile(`(?i)\b([a-z0-9]{2,5})\s*(?:格式|文件|产物|artifact|file|output)`)
openClawOutputTokenPattern = regexp.MustCompile(`(?i)(?:输出|导出|生成|制作)\s*([a-z0-9]{2,5})`)
openClawDottedExtensionPattern = regexp.MustCompile(`(?i)\.([a-z0-9]{2,5})\b`)
openClawFormatTokenPattern = regexp.MustCompile(`(?i)\b([a-z0-9]{2,5})\s*(?:格式|文件|产物|artifact|file|output)`)
openClawOutputTokenPattern = regexp.MustCompile(`(?i)(?:输出|导出|生成|制作)\s*([a-z0-9]{2,5})`)
openClawKnownArtifactExtensions = map[string]bool{
"csv": true,
"doc": true,
"docx": true,
"epub": true,
"gif": true,
"html": true,
"jpeg": true,
"jpg": true,
"json": true,
"md": true,
"mov": true,
"mp3": true,
"mp4": true,
"pdf": true,
"png": true,
"ppt": true,
"pptx": true,
"svg": true,
"txt": true,
"wav": true,
"webm": true,
"webp": true,
"xls": true,
"xlsx": true,
"zip": true,
}
)
func openClawArtifactContractForParams(params map[string]any, chatParams map[string]any) openClawArtifactContract {
@ -740,7 +788,7 @@ func normalizeOpenClawExtensionList(values []any) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
extension := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(fmt.Sprint(value))), ".")
extension := normalizeOpenClawArtifactExtension(fmt.Sprint(value))
if extension == "" || seen[extension] {
continue
}
@ -753,7 +801,7 @@ func normalizeOpenClawExtensionList(values []any) []string {
func extractOpenClawExtensionMentions(message string) []string {
result := make([]string, 0, 4)
add := func(value string) {
extension := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".")
extension := normalizeOpenClawArtifactExtension(value)
if extension == "" {
return
}
@ -782,6 +830,14 @@ func extractOpenClawExtensionMentions(message string) []string {
return result
}
func normalizeOpenClawArtifactExtension(value string) string {
extension := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".")
if extension == "" || !openClawKnownArtifactExtensions[extension] {
return ""
}
return extension
}
func openClawChatSendParams(
params map[string]any,
turnID string,
@ -1254,6 +1310,113 @@ func (o *SessionOrchestrator) openClawArtifactExport(
}
}
func (o *SessionOrchestrator) openClawFinalizeMissingArtifacts(
gatewayProvider string,
chatParams map[string]any,
sessionKey string,
runID string,
sinceUnixMs int64,
preparedArtifact *openClawPreparedArtifactScope,
contract openClawArtifactContract,
missing []string,
notify func(map[string]any),
) map[string]any {
if len(missing) == 0 || preparedArtifact == nil {
return nil
}
artifactDirectory := strings.TrimSpace(preparedArtifact.ArtifactDirectory)
if artifactDirectory == "" {
return nil
}
finalizeRunID := strings.TrimSpace(runID) + "-finalize"
if finalizeRunID == "-finalize" {
finalizeRunID = fmt.Sprintf("finalize-%d", time.Now().UnixNano())
}
finalizeParams := map[string]any{
"sessionKey": strings.TrimSpace(sessionKey),
"idempotencyKey": finalizeRunID,
"message": strings.Join([]string{
"XWorkmate final deliverable repair:",
"The previous run produced partial artifacts but missed required final deliverables.",
"Missing required artifact extensions: " + strings.Join(missing, ", ") + ".",
"Continue the same task. Do not restart from scratch unless necessary.",
"Use the existing artifactDirectory and write the missing final deliverables directly there.",
"artifactDirectory: " + artifactDirectory,
"After writing the files, reply with the relative paths of the final deliverables.",
}, "\n"),
}
if thinking := strings.TrimSpace(shared.StringArg(chatParams, "thinking", "")); thinking != "" {
finalizeParams["thinking"] = thinking
}
applyOpenClawPreparedArtifactToChatParams(finalizeParams, preparedArtifact, sessionKey, runID, contract)
sendStarted := time.Now()
sendResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"chat.send",
finalizeParams,
2*time.Minute,
notify,
)
logOpenClawGatewayTiming(
gatewayProvider,
"chat.send.finalize",
sessionKey,
finalizeRunID,
time.Since(sendStarted),
sendResult.OK,
)
if !sendResult.OK {
return openClawFinalizeWarningPayload(sendResult.Error, "openclaw final deliverable repair failed")
}
sendPayload := shared.AsMap(sendResult.Payload)
waitRunID := strings.TrimSpace(shared.StringArg(sendPayload, "runId", finalizeRunID))
waitStarted := time.Now()
waitResult := o.openClawGatewayRequestWithRetry(
gatewayProvider,
"agent.wait",
map[string]any{
"runId": waitRunID,
"timeoutMs": openClawAgentWaitDefaultTimeout.Milliseconds(),
},
openClawAgentWaitDefaultTimeout,
notify,
)
logOpenClawGatewayTiming(
gatewayProvider,
"agent.wait.finalize",
sessionKey,
waitRunID,
time.Since(waitStarted),
waitResult.OK,
)
if !waitResult.OK {
return openClawFinalizeWarningPayload(waitResult.Error, "openclaw final deliverable repair wait failed")
}
exportPayload := o.openClawArtifactExport(
gatewayProvider,
chatParams,
runID,
sinceUnixMs,
preparedArtifact,
notify,
)
if len(exportPayload) == 0 {
return nil
}
exportPayload["finalizeRunId"] = waitRunID
return exportPayload
}
func openClawFinalizeWarningPayload(errorPayload map[string]any, fallback string) map[string]any {
message := strings.TrimSpace(shared.StringArg(errorPayload, "message", ""))
if message == "" {
message = fallback
}
return map[string]any{
"artifactWarnings": []any{message},
}
}
func guardOpenClawNoDisplayableResult(result map[string]any, noDisplayableOutput bool) {
if !noDisplayableOutput || result == nil || !parseBool(result["success"]) {
return
@ -1287,12 +1450,7 @@ func applyOpenClawArtifactContractResult(result map[string]any, contract openCla
if !contract.ComplexLongChain || len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) {
return
}
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
artifacts := extractArtifactPayloads(result, remoteWorkingDirectory)
if len(artifacts) == 0 {
return
}
missing := missingOpenClawArtifactExtensions(artifacts, contract.RequiredFinalExtensions)
missing := missingOpenClawRequiredFinalExtensions(result, contract)
if len(missing) == 0 {
return
}
@ -1306,6 +1464,18 @@ func applyOpenClawArtifactContractResult(result map[string]any, contract openCla
result["missingArtifactExtensions"] = missing
}
func missingOpenClawRequiredFinalExtensions(result map[string]any, contract openClawArtifactContract) []string {
if result == nil || !contract.ComplexLongChain || len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) {
return nil
}
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
artifacts := extractArtifactPayloads(result, remoteWorkingDirectory)
if len(artifacts) == 0 {
return nil
}
return missingOpenClawArtifactExtensions(artifacts, contract.RequiredFinalExtensions)
}
func missingOpenClawArtifactExtensions(artifacts []map[string]any, required []string) []string {
seen := map[string]bool{}
for _, artifact := range artifacts {

View File

@ -749,7 +749,7 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractAcceptsRequiredFinalArt
}
}
func TestExecuteSessionTaskGatewayComplexArtifactContractFailsWithPartialArtifacts(t *testing.T) {
func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifacts(t *testing.T) {
gateway := newAcpFakeOpenClawGateway(t)
defer gateway.Close()
@ -778,20 +778,27 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractFailsWithPartialArtifac
},
})
if rpcErr != nil {
t.Fatalf("expected structured partial-artifact response, got rpc error: %#v", rpcErr)
t.Fatalf("expected finalized partial-artifact response, got rpc error: %#v", rpcErr)
}
if got := response["success"]; got != false {
t.Fatalf("expected partial artifact response to fail, got %#v", response)
if got := response["success"]; got != true {
t.Fatalf("expected partial artifact response to be finalized, got %#v", response)
}
if got := response["code"]; got != "OPENCLAW_REQUIRED_ARTIFACT_MISSING" {
t.Fatalf("expected required artifact missing code, got %#v", response)
if got := gateway.ChatSendCount(); got != 2 {
t.Fatalf("expected Bridge to send one finalize turn after partial artifacts, got %d", got)
}
artifacts := responseArtifactMaps(t, response)
if len(artifacts) != 1 || artifacts[0]["relativePath"] != "chapters/intro.md" {
t.Fatalf("expected partial artifact to remain exported, got %#v", artifacts)
if len(artifacts) != 3 {
t.Fatalf("expected initial partial artifact plus finalized export artifacts, got %#v", artifacts)
}
if got := response["missingArtifactExtensions"]; fmt.Sprint(got) != "[pdf]" {
t.Fatalf("expected missing PDF diagnostics, got %#v", response)
seen := map[string]bool{}
for _, artifact := range artifacts {
seen[fmt.Sprint(artifact["relativePath"])] = true
}
if !seen["chapters/intro.md"] || !seen["exports/final.pdf"] {
t.Fatalf("expected partial markdown and final PDF artifacts, got %#v", artifacts)
}
if _, ok := response["missingArtifactExtensions"]; ok {
t.Fatalf("expected finalize turn to clear missing artifact diagnostics, got %#v", response)
}
}
@ -2542,6 +2549,7 @@ type acpFakeOpenClawGateway struct {
agentWaitDelayMs atomic.Int64
largeGatewayPayloadBytes atomic.Int64
emitAgentDelta atomic.Bool
finalizeRequested atomic.Bool
lastConnectClient atomic.Value
lastChatSendParams atomic.Value
lastArtifactPrepareParams atomic.Value
@ -2670,7 +2678,11 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
if strings.TrimSpace(fake.alternateRunID) != "" {
runID = strings.TrimSpace(fake.alternateRunID)
}
fake.recordRunMessage(runID, strings.TrimSpace(shared.StringArg(params, "message", "")))
message := strings.TrimSpace(shared.StringArg(params, "message", ""))
if strings.Contains(message, "XWorkmate final deliverable repair:") {
fake.finalizeRequested.Store(true)
}
fake.recordRunMessage(runID, message)
_ = conn.WriteJSON(map[string]any{
"type": "res",
"id": id,
@ -2855,6 +2867,17 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
"content": "ZmluYWwgcmVwb3J0",
},
}
if fake.finalizeRequested.Load() {
payload["artifacts"] = append(payload["artifacts"].([]any), map[string]any{
"relativePath": "exports/final.pdf",
"label": "final.pdf",
"contentType": "application/pdf",
"sizeBytes": 12,
"sha256": "fake-sha256-final",
"artifactScope": artifactScope,
"scopeKind": "task",
})
}
}
if strings.Contains(fake.runMessage(runID), "make pdf artifact") {
payload["artifacts"] = []any{
@ -2881,6 +2904,17 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
"scopeKind": "task",
},
}
if fake.finalizeRequested.Load() {
payload["artifacts"] = append(payload["artifacts"].([]any), map[string]any{
"relativePath": "exports/final.pdf",
"label": "final.pdf",
"contentType": "application/pdf",
"sizeBytes": 12,
"sha256": "fake-sha256-final",
"artifactScope": artifactScope,
"scopeKind": "task",
})
}
}
_ = conn.WriteJSON(map[string]any{
"type": "res",

View File

@ -424,6 +424,7 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
"SOCKET_CLOSED",
"ACP_HTTP_CONNECTION_CLOSED",
"GATEWAY_CONNECT_FAILED",
"openclaw returned partial artifacts without required final deliverables",
} {
if strings.Contains(item.body, unexpected) {
t.Fatalf("unexpected gateway stability error %q in body: %s", unexpected, item.body)
@ -439,11 +440,12 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
if got := gateway.ConnectCount(); got != 1 {
t.Fatalf("expected bridge to reuse one established OpenClaw connection, got %d connects", got)
}
if got := gateway.ChatSendCount(); got != len(prompts) {
t.Fatalf("expected five chat.send calls, got %d", got)
expectedGatewayTurns := len(prompts) + 1
if got := gateway.ChatSendCount(); got != expectedGatewayTurns {
t.Fatalf("expected five primary chat.send calls plus one final-deliverable repair, got %d", got)
}
if got := gateway.AgentWaitCount(); got != len(prompts) {
t.Fatalf("expected five agent.wait calls, got %d", got)
if got := gateway.AgentWaitCount(); got != expectedGatewayTurns {
t.Fatalf("expected five primary agent.wait calls plus one final-deliverable repair, got %d", got)
}
}