diff --git a/internal/acp/orchestrator.go b/internal/acp/orchestrator.go index 3f9b5c7..f8d3d8c 100644 --- a/internal/acp/orchestrator.go +++ b/internal/acp/orchestrator.go @@ -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 { diff --git a/internal/acp/routing_test.go b/internal/acp/routing_test.go index a5d51f7..b8a4549 100644 --- a/internal/acp/routing_test.go +++ b/internal/acp/routing_test.go @@ -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", diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index 869f204..92bd811 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -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) } }