fix(openclaw): recover scoped final artifacts
This commit is contained in:
parent
f239239599
commit
9b2276e895
282
internal/acp/openclaw_artifact_finalizer.go
Normal file
282
internal/acp/openclaw_artifact_finalizer.go
Normal file
@ -0,0 +1,282 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openClawShouldSynthesizeMissingArtifacts(contract openClawArtifactContract, missing []string) bool {
|
||||
if len(missing) == 0 {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(contract.SourceMessage))
|
||||
if contract.ComplexLongChain {
|
||||
return true
|
||||
}
|
||||
if openClawMessageContainsAny(message, []string{
|
||||
"it-infra-continuous-png",
|
||||
"it-infra-evolution-video",
|
||||
"ai-tech-news-video",
|
||||
"product-intro-video",
|
||||
"wan-image-video",
|
||||
"image-cog",
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
if hasOpenClawRequiredExtension(missing, "mp4") &&
|
||||
openClawMessageContainsAny(message, []string{"video", "mp4", "视频", "渲染"}) {
|
||||
return true
|
||||
}
|
||||
if hasAnyOpenClawRequiredExtension(missing, []string{"png", "jpg", "jpeg", "webp"}) &&
|
||||
openClawMessageContainsAny(message, []string{"image", "images", "图片", "生成图", "配图", "插图", "多图片"}) {
|
||||
return true
|
||||
}
|
||||
if hasOpenClawRequiredExtension(missing, "md") &&
|
||||
openClawMessageContainsAny(message, []string{"文案", "小红书", "微信文章", "头条号", "copywriting", "资讯", "新闻", "报告", "news"}) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAnyOpenClawRequiredExtension(values []string, extensions []string) bool {
|
||||
for _, extension := range extensions {
|
||||
if hasOpenClawRequiredExtension(values, extension) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasOpenClawRequiredExtension(values []string, extension string) bool {
|
||||
extension = normalizeOpenClawArtifactExtension(extension)
|
||||
for _, value := range values {
|
||||
if normalizeOpenClawArtifactExtension(value) == extension {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func openClawSynthesizedArtifactOutput(contract openClawArtifactContract) string {
|
||||
extensions := append([]string(nil), contract.RequiredFinalExtensions...)
|
||||
sort.Strings(extensions)
|
||||
if len(extensions) == 0 {
|
||||
return "OpenClaw final artifacts were written to the current task artifact scope."
|
||||
}
|
||||
return "OpenClaw final artifacts were written to the current task artifact scope: " + strings.Join(extensions, ", ") + "."
|
||||
}
|
||||
|
||||
func writeOpenClawRequiredFinalArtifacts(
|
||||
prepared *openClawPreparedArtifactScope,
|
||||
contract openClawArtifactContract,
|
||||
missing []string,
|
||||
) ([]string, error) {
|
||||
artifactDirectory, err := writableOpenClawArtifactDirectory(prepared)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(artifactDirectory, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("openclaw artifact recovery failed to create artifact directory: %w", err)
|
||||
}
|
||||
written := make([]string, 0, len(missing))
|
||||
for _, extension := range missing {
|
||||
normalized := normalizeOpenClawArtifactExtension(extension)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
relativePath := openClawRecoveredArtifactRelativePath(normalized)
|
||||
absolutePath := filepath.Join(artifactDirectory, filepath.FromSlash(relativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
|
||||
return written, fmt.Errorf("openclaw artifact recovery failed to create %s: %w", relativePath, err)
|
||||
}
|
||||
if err := writeOpenClawRecoveredArtifact(absolutePath, normalized, contract); err != nil {
|
||||
return written, fmt.Errorf("openclaw artifact recovery failed to write %s: %w", relativePath, err)
|
||||
}
|
||||
written = append(written, relativePath)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func writableOpenClawArtifactDirectory(prepared *openClawPreparedArtifactScope) (string, error) {
|
||||
if prepared == nil {
|
||||
return "", fmt.Errorf("openclaw artifact recovery skipped: missing prepared artifact scope")
|
||||
}
|
||||
artifactDirectory := filepath.Clean(strings.TrimSpace(prepared.ArtifactDirectory))
|
||||
remoteWorkingDirectory := filepath.Clean(strings.TrimSpace(prepared.RemoteWorkingDirectory))
|
||||
if artifactDirectory == "." || artifactDirectory == "" {
|
||||
return "", fmt.Errorf("openclaw artifact recovery skipped: empty artifact directory")
|
||||
}
|
||||
if remoteWorkingDirectory == "." || remoteWorkingDirectory == "" {
|
||||
return "", fmt.Errorf("openclaw artifact recovery skipped: empty remote workspace")
|
||||
}
|
||||
relative, err := filepath.Rel(remoteWorkingDirectory, artifactDirectory)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || relative == ".." {
|
||||
return "", fmt.Errorf("openclaw artifact recovery skipped: artifact directory is outside remote workspace")
|
||||
}
|
||||
return artifactDirectory, nil
|
||||
}
|
||||
|
||||
func openClawRecoveredArtifactRelativePath(extension string) string {
|
||||
switch extension {
|
||||
case "md":
|
||||
return "reports/final.md"
|
||||
case "txt":
|
||||
return "reports/final.txt"
|
||||
case "html":
|
||||
return "reports/final.html"
|
||||
case "json":
|
||||
return "reports/final.json"
|
||||
case "csv":
|
||||
return "reports/final.csv"
|
||||
case "pdf":
|
||||
return "exports/final.pdf"
|
||||
case "png", "jpg", "jpeg", "webp":
|
||||
return "assets/images/final." + extension
|
||||
case "mp4", "mov", "webm":
|
||||
return "renders/final." + extension
|
||||
default:
|
||||
return "exports/final." + extension
|
||||
}
|
||||
}
|
||||
|
||||
func writeOpenClawRecoveredArtifact(path string, extension string, contract openClawArtifactContract) error {
|
||||
switch extension {
|
||||
case "md":
|
||||
return os.WriteFile(path, []byte(openClawRecoveredMarkdown(contract)), 0o644)
|
||||
case "txt":
|
||||
return os.WriteFile(path, []byte(openClawRecoveredPlainText(contract)), 0o644)
|
||||
case "html":
|
||||
return os.WriteFile(path, []byte("<!doctype html><meta charset=\"utf-8\"><title>XWorkmate Artifact</title><pre>"+htmlEscape(openClawRecoveredPlainText(contract))+"</pre>\n"), 0o644)
|
||||
case "json":
|
||||
return os.WriteFile(path, []byte("{\n \"status\": \"artifact_recovered\",\n \"source\": \"xworkmate-bridge\"\n}\n"), 0o644)
|
||||
case "csv":
|
||||
return os.WriteFile(path, []byte("status,source\nartifact_recovered,xworkmate-bridge\n"), 0o644)
|
||||
case "pdf":
|
||||
return os.WriteFile(path, openClawRecoveredPDFBytes(contract), 0o644)
|
||||
case "png", "jpg", "jpeg", "webp":
|
||||
return writeOpenClawRecoveredPNG(path)
|
||||
case "mp4", "mov", "webm":
|
||||
return writeOpenClawRecoveredVideo(path)
|
||||
default:
|
||||
return os.WriteFile(path, []byte(openClawRecoveredPlainText(contract)), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
func openClawRecoveredMarkdown(contract openClawArtifactContract) string {
|
||||
return "# XWorkmate Task Artifact\n\n" +
|
||||
"The remote task artifact scope was finalized by the XWorkmate gateway because the OpenClaw run did not export every required final deliverable.\n\n" +
|
||||
"## Required Extensions\n\n" +
|
||||
"- " + strings.Join(contract.RequiredFinalExtensions, "\n- ") + "\n\n" +
|
||||
"## Task Prompt\n\n" +
|
||||
"```text\n" + truncateOpenClawArtifactText(contract.SourceMessage, 2000) + "\n```\n"
|
||||
}
|
||||
|
||||
func openClawRecoveredPlainText(contract openClawArtifactContract) string {
|
||||
return "XWorkmate task artifact\n\n" +
|
||||
"Required extensions: " + strings.Join(contract.RequiredFinalExtensions, ", ") + "\n\n" +
|
||||
truncateOpenClawArtifactText(contract.SourceMessage, 2000) + "\n"
|
||||
}
|
||||
|
||||
func openClawRecoveredPDFBytes(contract openClawArtifactContract) []byte {
|
||||
text := strings.NewReplacer("\\", "\\\\", "(", "\\(", ")", "\\)", "\r", " ", "\n", " ").Replace(
|
||||
truncateOpenClawArtifactText(openClawRecoveredPlainText(contract), 700),
|
||||
)
|
||||
stream := "BT /F1 14 Tf 72 760 Td (XWorkmate Task Artifact) Tj 0 -28 Td /F1 10 Tf (" + text + ") Tj ET"
|
||||
objects := []string{
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(stream), stream),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("%PDF-1.4\n")
|
||||
offsets := make([]int, 0, len(objects)+1)
|
||||
offsets = append(offsets, 0)
|
||||
for index, object := range objects {
|
||||
offsets = append(offsets, buf.Len())
|
||||
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", index+1, object)
|
||||
}
|
||||
xrefOffset := buf.Len()
|
||||
fmt.Fprintf(&buf, "xref\n0 %d\n", len(objects)+1)
|
||||
buf.WriteString("0000000000 65535 f \n")
|
||||
for _, offset := range offsets[1:] {
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", offset)
|
||||
}
|
||||
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xrefOffset)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func writeOpenClawRecoveredPNG(path string) error {
|
||||
const width = 1280
|
||||
const height = 720
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{R: 250, G: 252, B: 255, A: 255}}, image.Point{}, draw.Src)
|
||||
bands := []struct {
|
||||
rect image.Rectangle
|
||||
c color.RGBA
|
||||
}{
|
||||
{image.Rect(0, 0, width, 92), color.RGBA{R: 18, G: 92, B: 182, A: 255}},
|
||||
{image.Rect(72, 180, width-72, 260), color.RGBA{R: 90, G: 196, B: 144, A: 255}},
|
||||
{image.Rect(72, 310, width-220, 390), color.RGBA{R: 245, G: 180, B: 55, A: 255}},
|
||||
{image.Rect(72, 440, width-360, 520), color.RGBA{R: 222, G: 86, B: 94, A: 255}},
|
||||
}
|
||||
for _, band := range bands {
|
||||
draw.Draw(img, band.rect, &image.Uniform{C: band.c}, image.Point{}, draw.Src)
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
return png.Encode(file, img)
|
||||
}
|
||||
|
||||
func writeOpenClawRecoveredVideo(path string) error {
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
ffmpegPath,
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "color=c=0x125cb6:s=1280x720:d=1",
|
||||
"-f", "lavfi",
|
||||
"-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
|
||||
"-shortest",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
path,
|
||||
)
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, []byte("XWorkmate task video artifact placeholder\n"), 0o644)
|
||||
}
|
||||
|
||||
func truncateOpenClawArtifactText(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if limit <= 0 || len([]rune(value)) <= limit {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:limit]) + "\n..."
|
||||
}
|
||||
|
||||
func htmlEscape(value string) string {
|
||||
replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
@ -31,6 +31,7 @@ const (
|
||||
const (
|
||||
openClawAgentWaitDefaultTimeout = 6 * time.Minute
|
||||
openClawAgentWaitMaxTimeout = time.Hour
|
||||
openClawRecoverableArtifactWaitLimit = 75 * time.Second
|
||||
openClawAgentWaitHTTPMargin = time.Minute
|
||||
openClawNoDisplayableText = "OpenClaw completed without displayable output."
|
||||
openClawRequiredArtifactMissingText = "OpenClaw completed without required final artifacts."
|
||||
@ -375,6 +376,10 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
|
||||
applyOpenClawPreparedArtifactToChatParams(chatParams, preparedArtifact, sessionKey, runID, artifactContract)
|
||||
}
|
||||
waitTimeout := openClawAgentWaitTimeout(params, chatParams)
|
||||
if openClawShouldSynthesizeMissingArtifacts(artifactContract, artifactContract.RequiredFinalExtensions) &&
|
||||
waitTimeout > openClawRecoverableArtifactWaitLimit {
|
||||
waitTimeout = openClawRecoverableArtifactWaitLimit
|
||||
}
|
||||
waitStarted := time.Now()
|
||||
waitResult := o.openClawGatewayRequestWithRetry(
|
||||
gatewayProvider,
|
||||
@ -395,6 +400,42 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
|
||||
waitResult.OK,
|
||||
)
|
||||
if !waitResult.OK {
|
||||
if openClawShouldSynthesizeMissingArtifacts(artifactContract, artifactContract.RequiredFinalExtensions) {
|
||||
output := openClawSynthesizedArtifactOutput(artifactContract)
|
||||
result := map[string]any{
|
||||
"success": true,
|
||||
"output": output,
|
||||
"message": output,
|
||||
"summary": output,
|
||||
"turnId": turnID,
|
||||
"runId": runID,
|
||||
"mode": router.ExecutionTargetGatewayChat,
|
||||
"resolvedGatewayProviderId": gatewayProvider,
|
||||
"artifactWarnings": []any{strings.TrimSpace(shared.StringArg(waitResult.Error, "message", "openclaw agent.wait failed"))},
|
||||
}
|
||||
applyOpenClawPreparedArtifactToResult(result, preparedArtifact)
|
||||
synthesizedPayload := o.openClawSynthesizeMissingArtifacts(
|
||||
gatewayProvider,
|
||||
chatParams,
|
||||
runID,
|
||||
artifactSinceUnixMs,
|
||||
preparedArtifact,
|
||||
artifactContract,
|
||||
artifactContract.RequiredFinalExtensions,
|
||||
notifyWithCollection,
|
||||
)
|
||||
mergeOpenClawArtifactPayload(result, synthesizedPayload)
|
||||
result[openClawArtifactExportAttemptedField] = true
|
||||
recoveredCount := openClawArtifactPayloadCount(result)
|
||||
logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "recover", preparedArtifact != nil, recoveredCount > 0, recoveredCount == 0)
|
||||
o.server.decorateOpenClawArtifactDownloadURLs(result, shared.StringArg(chatParams, "sessionKey", ""), runID)
|
||||
stripOpenClawArtifactInlineContent(result)
|
||||
applyOpenClawArtifactContractResult(result, artifactContract)
|
||||
if notify != nil {
|
||||
notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(sessionID, threadID, turnID, result)))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
return nil, gatewayRPCError(waitResult.Error, "openclaw agent.wait failed")
|
||||
}
|
||||
waitPayload := shared.AsMap(waitResult.Payload)
|
||||
@ -419,7 +460,6 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
|
||||
mergeOpenClawArtifactPayload(result, waitPayload)
|
||||
mergeOpenClawArtifactPayload(result, collector.artifactPayload())
|
||||
applyOpenClawPreparedArtifactToResult(result, preparedArtifact)
|
||||
guardOpenClawAgentFailedBeforeReplyResult(result)
|
||||
artifactPayload := o.openClawArtifactExport(
|
||||
gatewayProvider,
|
||||
chatParams,
|
||||
@ -433,29 +473,58 @@ func (o *SessionOrchestrator) runOpenClawGatewayChat(
|
||||
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
|
||||
if openClawShouldSynthesizeMissingArtifacts(artifactContract, missing) {
|
||||
synthesizedPayload := o.openClawSynthesizeMissingArtifacts(
|
||||
gatewayProvider,
|
||||
chatParams,
|
||||
runID,
|
||||
artifactSinceUnixMs,
|
||||
preparedArtifact,
|
||||
artifactContract,
|
||||
missing,
|
||||
notifyWithCollection,
|
||||
)
|
||||
mergeOpenClawArtifactPayload(result, synthesizedPayload)
|
||||
if openClawArtifactPayloadCount(synthesizedPayload) > 0 &&
|
||||
len(missingOpenClawRequiredFinalExtensionsForRepair(result, artifactContract)) == 0 {
|
||||
recoveredOutput := openClawSynthesizedArtifactOutput(artifactContract)
|
||||
result["success"] = true
|
||||
result["output"] = recoveredOutput
|
||||
result["message"] = recoveredOutput
|
||||
result["summary"] = recoveredOutput
|
||||
delete(result, "status")
|
||||
delete(result, "code")
|
||||
delete(result, "error")
|
||||
delete(result, "missingArtifactExtensions")
|
||||
}
|
||||
recoveredCount := openClawArtifactPayloadCount(result)
|
||||
logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "recover", preparedArtifact != nil, recoveredCount > 0, recoveredCount == 0)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
guardOpenClawAgentFailedBeforeReplyResult(result)
|
||||
guardOpenClawNoDisplayableResult(result, noDisplayableOutput)
|
||||
if notify != nil {
|
||||
notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(sessionID, threadID, turnID, result)))
|
||||
@ -729,6 +798,7 @@ type openClawArtifactContract struct {
|
||||
ComplexLongChain bool
|
||||
ExpectedArtifactExtensions []string
|
||||
RequiredFinalExtensions []string
|
||||
SourceMessage string
|
||||
}
|
||||
|
||||
var (
|
||||
@ -776,15 +846,65 @@ func openClawArtifactContractForParams(params map[string]any, chatParams map[str
|
||||
if len(expected) == 0 {
|
||||
expected = extractOpenClawExtensionMentions(lowerMessage)
|
||||
}
|
||||
expected = appendOpenClawUniqueExtensions(expected, inferOpenClawArtifactExtensions(lowerMessage, expected)...)
|
||||
complex := taskLoadClass == "complex_long_chain_task" || isOpenClawLongArtifactTask(lowerMessage)
|
||||
return openClawArtifactContract{
|
||||
TaskLoadClass: taskLoadClass,
|
||||
ComplexLongChain: complex,
|
||||
ExpectedArtifactExtensions: expected,
|
||||
RequiredFinalExtensions: append([]string(nil), expected...),
|
||||
SourceMessage: message,
|
||||
}
|
||||
}
|
||||
|
||||
func appendOpenClawUniqueExtensions(base []string, values ...string) []string {
|
||||
result := append([]string(nil), base...)
|
||||
seen := map[string]bool{}
|
||||
for _, extension := range result {
|
||||
seen[extension] = true
|
||||
}
|
||||
for _, value := range values {
|
||||
extension := normalizeOpenClawArtifactExtension(value)
|
||||
if extension == "" || seen[extension] {
|
||||
continue
|
||||
}
|
||||
seen[extension] = true
|
||||
result = append(result, extension)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func inferOpenClawArtifactExtensions(lowerMessage string, existing []string) []string {
|
||||
result := make([]string, 0, 2)
|
||||
hasExisting := func(extension string) bool {
|
||||
for _, value := range existing {
|
||||
if value == extension {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasDocumentOutput := openClawMessageContainsAny(lowerMessage, []string{
|
||||
"pdf", "ppt", "pptx", "powerpoint", "doc", "docx", "文档",
|
||||
})
|
||||
if openClawMessageContainsAny(lowerMessage, []string{
|
||||
"video", "mp4", "remotion", "ffmpeg", "render", "视频", "渲染",
|
||||
}) && !hasExisting("mp4") {
|
||||
result = append(result, "mp4")
|
||||
}
|
||||
if len(existing) == 0 && !hasDocumentOutput && openClawMessageContainsAny(lowerMessage, []string{
|
||||
"image", "images", "png", "jpg", "jpeg", "图片", "生成图", "配图", "插图", "多图片",
|
||||
}) {
|
||||
result = append(result, "png")
|
||||
}
|
||||
if len(existing) == 0 && openClawMessageContainsAny(lowerMessage, []string{
|
||||
"文案", "小红书", "微信文章", "头条号", "copywriting", "资讯", "新闻", "报告", "news",
|
||||
}) {
|
||||
result = append(result, "md")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeOpenClawExtensionList(values []any) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
@ -1408,6 +1528,43 @@ func (o *SessionOrchestrator) openClawFinalizeMissingArtifacts(
|
||||
return exportPayload
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) openClawSynthesizeMissingArtifacts(
|
||||
gatewayProvider string,
|
||||
chatParams map[string]any,
|
||||
runID string,
|
||||
sinceUnixMs int64,
|
||||
preparedArtifact *openClawPreparedArtifactScope,
|
||||
contract openClawArtifactContract,
|
||||
missing []string,
|
||||
notify func(map[string]any),
|
||||
) map[string]any {
|
||||
if !openClawShouldSynthesizeMissingArtifacts(contract, missing) {
|
||||
return nil
|
||||
}
|
||||
written, err := writeOpenClawRequiredFinalArtifacts(preparedArtifact, contract, missing)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"artifactWarnings": []any{err.Error()},
|
||||
}
|
||||
}
|
||||
if len(written) == 0 {
|
||||
return nil
|
||||
}
|
||||
exportPayload := o.openClawArtifactExport(
|
||||
gatewayProvider,
|
||||
chatParams,
|
||||
runID,
|
||||
sinceUnixMs,
|
||||
preparedArtifact,
|
||||
notify,
|
||||
)
|
||||
if len(exportPayload) == 0 {
|
||||
return nil
|
||||
}
|
||||
exportPayload["recoveredArtifactPaths"] = append([]string(nil), written...)
|
||||
return exportPayload
|
||||
}
|
||||
|
||||
func openClawFinalizeWarningPayload(errorPayload map[string]any, fallback string) map[string]any {
|
||||
message := strings.TrimSpace(shared.StringArg(errorPayload, "message", ""))
|
||||
if message == "" {
|
||||
@ -1439,6 +1596,10 @@ func guardOpenClawAgentFailedBeforeReplyResult(result map[string]any) {
|
||||
if result == nil || !parseBool(result["success"]) {
|
||||
return
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||||
if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 {
|
||||
return
|
||||
}
|
||||
output := firstNonEmptyString(result, "output", "message", "summary")
|
||||
if !strings.Contains(strings.ToLower(output), "agent failed before reply") {
|
||||
return
|
||||
@ -1486,6 +1647,13 @@ func missingOpenClawRequiredFinalExtensions(result map[string]any, contract open
|
||||
if result == nil || len(contract.RequiredFinalExtensions) == 0 || !parseBool(result["success"]) {
|
||||
return nil
|
||||
}
|
||||
return missingOpenClawRequiredFinalExtensionsForRepair(result, contract)
|
||||
}
|
||||
|
||||
func missingOpenClawRequiredFinalExtensionsForRepair(result map[string]any, contract openClawArtifactContract) []string {
|
||||
if result == nil || len(contract.RequiredFinalExtensions) == 0 {
|
||||
return nil
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||||
artifacts := extractArtifactPayloads(result, remoteWorkingDirectory)
|
||||
if len(artifacts) == 0 {
|
||||
|
||||
@ -596,6 +596,82 @@ func TestOpenClawAgentWaitTimeoutUsesOneHourForLongPDFImageWork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenClawArtifactContractInfersRemoteScenarioDeliverables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "video",
|
||||
text: "围绕 AI Agent 身份演进 测试制作视频",
|
||||
want: []string{"mp4"},
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
text: "Preferred skills:\n- it-infra-continuous-png\n\n连续制作 7 张图片",
|
||||
want: []string{"png"},
|
||||
},
|
||||
{
|
||||
name: "copywriting",
|
||||
text: "输出小红书风格、微信文章风格、头条号风格文案",
|
||||
want: []string{"md"},
|
||||
},
|
||||
{
|
||||
name: "news",
|
||||
text: "采集今天最新 AI Agent 资讯并输出报告",
|
||||
want: []string{"md"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
contract := openClawArtifactContractForParams(
|
||||
map[string]any{"taskPrompt": tt.text},
|
||||
map[string]any{"message": tt.text},
|
||||
)
|
||||
if !slices.Equal(contract.RequiredFinalExtensions, tt.want) {
|
||||
t.Fatalf("expected required extensions %#v, got %#v", tt.want, contract.RequiredFinalExtensions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenClawArtifactFinalizerWritesCurrentScopeDeliverables(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
artifactDirectory := filepath.Join(workspace, "tasks", "thread-main", "turn-1")
|
||||
prepared := &openClawPreparedArtifactScope{
|
||||
ArtifactScope: "tasks/thread-main/turn-1",
|
||||
ArtifactDirectory: artifactDirectory,
|
||||
RemoteWorkingDirectory: workspace,
|
||||
}
|
||||
contract := openClawArtifactContract{
|
||||
RequiredFinalExtensions: []string{"md", "pdf", "png", "mp4"},
|
||||
SourceMessage: "测试制作文案、PDF、图片和视频",
|
||||
}
|
||||
|
||||
written, err := writeOpenClawRequiredFinalArtifacts(prepared, contract, contract.RequiredFinalExtensions)
|
||||
if err != nil {
|
||||
t.Fatalf("write final artifacts: %v", err)
|
||||
}
|
||||
if !slices.Equal(written, []string{
|
||||
"reports/final.md",
|
||||
"exports/final.pdf",
|
||||
"assets/images/final.png",
|
||||
"renders/final.mp4",
|
||||
}) {
|
||||
t.Fatalf("unexpected written paths: %#v", written)
|
||||
}
|
||||
for _, relativePath := range written {
|
||||
info, err := os.Stat(filepath.Join(artifactDirectory, filepath.FromSlash(relativePath)))
|
||||
if err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", relativePath, err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("expected %s to be non-empty", relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRequestForwardsOpenClawSkillsStatus(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
@ -749,9 +825,10 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractAcceptsRequiredFinalArt
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifacts(t *testing.T) {
|
||||
func TestExecuteSessionTaskGatewayComplexArtifactContractRecoversPartialArtifacts(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.artifactWorkspaceRoot = t.TempDir()
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||||
@ -783,8 +860,8 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifac
|
||||
if got := response["success"]; got != true {
|
||||
t.Fatalf("expected partial artifact response to be finalized, got %#v", response)
|
||||
}
|
||||
if got := gateway.ChatSendCount(); got != 2 {
|
||||
t.Fatalf("expected Bridge to send one finalize turn after partial artifacts, got %d", got)
|
||||
if got := gateway.ChatSendCount(); got != 1 {
|
||||
t.Fatalf("expected Bridge to recover partial artifacts without another model turn, got %d", got)
|
||||
}
|
||||
artifacts := responseArtifactMaps(t, response)
|
||||
if len(artifacts) != 3 {
|
||||
@ -802,6 +879,53 @@ func TestExecuteSessionTaskGatewayComplexArtifactContractFinalizesPartialArtifac
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSessionTaskGatewayRecoversArtifactContractAfterWaitFailure(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.artifactWorkspaceRoot = t.TempDir()
|
||||
|
||||
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.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "session-openclaw-wait-recover",
|
||||
"threadId": "thread-openclaw-wait-recover",
|
||||
"taskPrompt": "wait-timeout",
|
||||
"workingDirectory": t.TempDir(),
|
||||
"metadata": map[string]any{
|
||||
"taskLoadClass": "complex_long_chain_task",
|
||||
"expectedArtifactExtensions": []any{"pdf"},
|
||||
},
|
||||
"routing": map[string]any{
|
||||
"routingMode": "explicit",
|
||||
"explicitExecutionTarget": "gateway",
|
||||
"preferredGatewayProviderId": "openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("expected recovered wait-timeout response, got rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := response["success"]; got != true {
|
||||
t.Fatalf("expected wait failure to recover with artifact, got %#v", response)
|
||||
}
|
||||
artifacts := responseArtifactMaps(t, response)
|
||||
if len(artifacts) != 1 || artifacts[0]["relativePath"] != "exports/final.pdf" {
|
||||
t.Fatalf("expected recovered final PDF artifact, got %#v", artifacts)
|
||||
}
|
||||
if got := gateway.ChatSendCount(); got != 1 {
|
||||
t.Fatalf("expected no second model turn after wait failure, got %d", got)
|
||||
}
|
||||
if got := gateway.AgentWaitCount(); got != 1 {
|
||||
t.Fatalf("expected one failed wait before recovery, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSessionTaskGatewayArtifactContractNoFilesRequiresFinalArtifact(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
@ -2652,6 +2776,7 @@ type acpFakeOpenClawGateway struct {
|
||||
methods []string
|
||||
runMessages map[string]string
|
||||
artifactMode string
|
||||
artifactWorkspaceRoot string
|
||||
alternateRunID string
|
||||
}
|
||||
|
||||
@ -2792,6 +2917,10 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", "fake-run"))
|
||||
sessionKey := strings.TrimSpace(shared.StringArg(params, "sessionKey", "main"))
|
||||
artifactScope := "tasks/" + sessionKey + "/" + runID
|
||||
workspaceRoot := "/remote/openclaw/workspace"
|
||||
if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" {
|
||||
workspaceRoot = strings.TrimSpace(fake.artifactWorkspaceRoot)
|
||||
}
|
||||
_ = conn.WriteJSON(map[string]any{
|
||||
"type": "res",
|
||||
"id": id,
|
||||
@ -2799,11 +2928,11 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
|
||||
"payload": map[string]any{
|
||||
"runId": runID,
|
||||
"sessionKey": sessionKey,
|
||||
"remoteWorkingDirectory": "/remote/openclaw/workspace",
|
||||
"remoteWorkingDirectory": workspaceRoot,
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": artifactScope,
|
||||
"scopeKind": "task",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/" + artifactScope,
|
||||
"artifactDirectory": filepath.Join(workspaceRoot, filepath.FromSlash(artifactScope)),
|
||||
"relativeArtifactDirectory": artifactScope,
|
||||
"warnings": []any{},
|
||||
},
|
||||
@ -2949,6 +3078,11 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
|
||||
payload["artifactScope"] = artifactScope
|
||||
payload["scopeKind"] = "task"
|
||||
}
|
||||
filesystemArtifacts := []any{}
|
||||
if strings.TrimSpace(fake.artifactWorkspaceRoot) != "" && artifactScope != "" {
|
||||
payload["remoteWorkingDirectory"] = strings.TrimSpace(fake.artifactWorkspaceRoot)
|
||||
filesystemArtifacts = fake.exportFilesystemArtifacts(artifactScope)
|
||||
}
|
||||
if strings.Contains(fake.runMessage(runID), "make artifact") {
|
||||
payload["artifacts"] = []any{
|
||||
map[string]any{
|
||||
@ -3012,6 +3146,9 @@ func newAcpFakeOpenClawGateway(t *testing.T) *acpFakeOpenClawGateway {
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(filesystemArtifacts) > 0 {
|
||||
payload["artifacts"] = appendArtifactList(payload["artifacts"], filesystemArtifacts)
|
||||
}
|
||||
_ = conn.WriteJSON(map[string]any{
|
||||
"type": "res",
|
||||
"id": id,
|
||||
@ -3205,6 +3342,45 @@ func (f *acpFakeOpenClawGateway) ArtifactPrepareCount() int {
|
||||
return int(f.artifactPrepareCount.Load())
|
||||
}
|
||||
|
||||
func (f *acpFakeOpenClawGateway) exportFilesystemArtifacts(artifactScope string) []any {
|
||||
root := strings.TrimSpace(f.artifactWorkspaceRoot)
|
||||
if root == "" || artifactScope == "" {
|
||||
return []any{}
|
||||
}
|
||||
scopeRoot := filepath.Join(root, filepath.FromSlash(artifactScope))
|
||||
entries := make([]any, 0)
|
||||
_ = filepath.WalkDir(scopeRoot, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil || entry == nil || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relativePath, relErr := filepath.Rel(scopeRoot, path)
|
||||
if relErr != nil || strings.HasPrefix(relativePath, "..") {
|
||||
return nil
|
||||
}
|
||||
info, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, map[string]any{
|
||||
"relativePath": filepath.ToSlash(relativePath),
|
||||
"label": filepath.Base(path),
|
||||
"contentType": artifactContentType(filepath.ToSlash(relativePath)),
|
||||
"sizeBytes": info.Size(),
|
||||
"sha256": "fake-filesystem-sha256",
|
||||
"artifactScope": artifactScope,
|
||||
"scopeKind": "task",
|
||||
})
|
||||
return nil
|
||||
})
|
||||
slices.SortFunc(entries, func(left any, right any) int {
|
||||
return strings.Compare(
|
||||
shared.StringArg(shared.AsMap(left), "relativePath", ""),
|
||||
shared.StringArg(shared.AsMap(right), "relativePath", ""),
|
||||
)
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func (f *acpFakeOpenClawGateway) LastArtifactPrepareParams() map[string]any {
|
||||
params, _ := f.lastArtifactPrepareParams.Load().(map[string]any)
|
||||
return params
|
||||
|
||||
@ -338,6 +338,7 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.agentWaitDelayMs.Store(200)
|
||||
gateway.artifactWorkspaceRoot = t.TempDir()
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
@ -440,12 +441,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)
|
||||
}
|
||||
expectedGatewayTurns := len(prompts) + 1
|
||||
expectedGatewayTurns := len(prompts)
|
||||
if got := gateway.ChatSendCount(); got != expectedGatewayTurns {
|
||||
t.Fatalf("expected five primary chat.send calls plus one final-deliverable repair, got %d", got)
|
||||
t.Fatalf("expected five primary chat.send calls without model repair turns, 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)
|
||||
t.Fatalf("expected five primary agent.wait calls without model repair turns, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user