fix(openclaw): finalize missing required artifacts
This commit is contained in:
parent
0b31ccf461
commit
ad5d0ab989
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user