A gateway that doesn't implement xworkmate.session.prepare returns an
"unknown method: xworkmate.session.prepare" error. isOpenClawUnknownMethodError
gated on a string code allowlist {"", INVALID_REQUEST, METHOD_NOT_FOUND}, but real
gateways send a numeric JSON-RPC code (e.g. -32002 / -32601) which shared.StringArg
stringifies to "-32002". The matcher then returned false, so the graceful fallback
(openClawFallbackSessionPreparePayload) never fired and every turn hard-failed with
"-32002: unknown method: xworkmate.session.prepare".
Match on the unambiguous message ("unknown method" + the method name) instead of the
stringified numeric code. Add a regression test covering numeric codes and guarding
against swallowing unrelated errors / other method names.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2304 lines
74 KiB
Go
2304 lines
74 KiB
Go
package acp
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"log"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"xworkmate-bridge/internal/gatewayruntime"
|
||
"xworkmate-bridge/internal/memory"
|
||
"xworkmate-bridge/internal/router"
|
||
"xworkmate-bridge/internal/shared"
|
||
)
|
||
|
||
type SessionOrchestrator struct {
|
||
server *Server
|
||
}
|
||
|
||
const (
|
||
openClawInlineAttachmentMaxFileBytes = 10 * 1024 * 1024
|
||
openClawInlineAttachmentMaxTotalBytes = 25 * 1024 * 1024
|
||
)
|
||
|
||
const (
|
||
openClawAgentWaitDefaultTimeout = 6 * time.Minute
|
||
openClawAgentWaitMaxTimeout = time.Hour
|
||
openClawAgentWaitHTTPMargin = time.Minute
|
||
openClawNoDisplayableText = "OpenClaw completed without displayable output."
|
||
openClawRequiredArtifactMissingText = "OpenClaw completed without required final artifacts."
|
||
openClawArtifactExportAttemptedField = "_openClawArtifactExportAttempted"
|
||
)
|
||
|
||
func NewSessionOrchestrator(server *Server) *SessionOrchestrator {
|
||
return &SessionOrchestrator{server: server}
|
||
}
|
||
|
||
func (o *SessionOrchestrator) Process(ctx context.Context, method string, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
|
||
res, err := o.server.routingEngine.Resolve(ctx, params)
|
||
if err != nil {
|
||
if err.Error() == "ROUTING_REQUIRED" {
|
||
return nil, &shared.RPCError{Code: -32602, Message: err.Error()}
|
||
}
|
||
return nil, &shared.RPCError{Code: -32602, Message: "ROUTING_FAILED: " + err.Error()}
|
||
}
|
||
|
||
if res.Status == "unavailable" {
|
||
return o.formatUnavailable(res), nil
|
||
}
|
||
|
||
sessionID := shared.StringArg(params, "sessionId", "")
|
||
threadID := shared.StringArg(params, "threadId", sessionID)
|
||
turnID := fmt.Sprintf("turn-%d", time.Now().UnixNano())
|
||
|
||
sess := o.server.getOrCreateSession(sessionID, threadID)
|
||
sess.mu.Lock()
|
||
sess.target = res.TargetID
|
||
sess.provider = res.ProviderID
|
||
sess.mode = res.TargetID
|
||
sess.control.ControlPlaneSessionID = sessionID
|
||
sess.control.ThreadID = threadID
|
||
sess.control.RequestedWorkingDir = strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
|
||
sess.control.RemoteWorkingDirHint = strings.TrimSpace(shared.StringArg(params, "remoteWorkingDirectoryHint", ""))
|
||
sess.control.UpdatedAt = time.Now()
|
||
sess.task = QueuedTask{
|
||
SessionID: sessionID,
|
||
ThreadID: threadID,
|
||
TurnID: turnID,
|
||
Provider: res.ProviderID,
|
||
Target: res.TargetID,
|
||
State: TaskStateRunning,
|
||
Kind: taskKindFromParams(params, res),
|
||
UpdatedAt: time.Now(),
|
||
}
|
||
prompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
|
||
if prompt != "" {
|
||
sess.history = append(sess.history, "USER: "+prompt)
|
||
}
|
||
sess.mu.Unlock()
|
||
|
||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||
"type": "status",
|
||
"event": "started",
|
||
"message": "session started",
|
||
"pending": true,
|
||
"error": false,
|
||
})
|
||
|
||
if res.TargetID == "gateway" {
|
||
result, rpcErr := o.runGateway(ctx, method, params, res, turnID, notify)
|
||
if rpcErr != nil {
|
||
failed := o.normalizeGatewayFailureResult(sess, rpcErr, res, turnID)
|
||
if (isOpenClawProvider(res.GatewayProviderID) || isOpenClawProvider(res.ProviderID)) &&
|
||
!openClawGatewayBusyError(rpcErr) {
|
||
o.server.emitSessionUpdate(notify, turnID, openClawGatewayCompletedResultUpdate(sessionID, threadID, turnID, failed))
|
||
}
|
||
return nil, rpcErr
|
||
}
|
||
return o.normalizeResult(sess, result, res, turnID, params), nil
|
||
}
|
||
|
||
compat, ok := o.server.providers[res.ProviderID]
|
||
if !ok {
|
||
return nil, &shared.RPCError{Code: -32001, Message: "PROVIDER_NOT_FOUND: " + res.ProviderID}
|
||
}
|
||
|
||
sess.mu.Lock()
|
||
sess.compat = compat
|
||
sess.mu.Unlock()
|
||
|
||
sink := func(update map[string]any) {
|
||
o.server.emitSessionUpdate(notify, turnID, update)
|
||
}
|
||
|
||
var result map[string]any
|
||
switch method {
|
||
case "session.start":
|
||
result, err = compat.StartSession(ctx, sessionID, threadID, params, sink)
|
||
case "session.message":
|
||
result, err = compat.SendMessage(ctx, sessionID, threadID, params, sink)
|
||
default:
|
||
err = fmt.Errorf("unsupported session method: %s", method)
|
||
}
|
||
if err != nil {
|
||
sess.mu.Lock()
|
||
sess.task.State = TaskStateFailed
|
||
sess.task.UpdatedAt = time.Now()
|
||
sess.mu.Unlock()
|
||
if continuationErr, ok := asSessionContinuationUnavailableError(err); ok {
|
||
return nil, sessionContinuationUnavailableRPCError(continuationErr)
|
||
}
|
||
return nil, &shared.RPCError{Code: -32002, Message: "EXECUTION_FAILED: " + err.Error()}
|
||
}
|
||
|
||
return o.normalizeResult(sess, result, res, turnID, params), nil
|
||
}
|
||
|
||
func openClawGatewayBusyError(rpcErr *shared.RPCError) bool {
|
||
if rpcErr == nil {
|
||
return false
|
||
}
|
||
data := shared.AsMap(rpcErr.Data)
|
||
return strings.TrimSpace(shared.StringArg(data, "code", "")) == "OPENCLAW_GATEWAY_BUSY"
|
||
}
|
||
|
||
func (o *SessionOrchestrator) normalizeGatewayFailureResult(
|
||
sess *session,
|
||
rpcErr *shared.RPCError,
|
||
routing RoutingResult,
|
||
turnID string,
|
||
) map[string]any {
|
||
message := "gateway execution failed"
|
||
code := "GATEWAY_EXECUTION_FAILED"
|
||
if rpcErr != nil {
|
||
if strings.TrimSpace(rpcErr.Message) != "" {
|
||
message = strings.TrimSpace(rpcErr.Message)
|
||
}
|
||
data := shared.AsMap(rpcErr.Data)
|
||
if dataCode := strings.TrimSpace(shared.StringArg(data, "code", "")); dataCode != "" {
|
||
code = dataCode
|
||
}
|
||
}
|
||
result := map[string]any{
|
||
"success": false,
|
||
"status": "failed",
|
||
"code": code,
|
||
"error": message,
|
||
"message": message,
|
||
"summary": message,
|
||
"output": message,
|
||
"turnId": turnID,
|
||
"mode": router.ExecutionTargetGatewayChat,
|
||
"resolvedExecutionTarget": routing.TargetID,
|
||
"resolvedProviderId": routing.ProviderID,
|
||
"resolvedGatewayProviderId": routing.GatewayProviderID,
|
||
"resolvedModel": routing.Model,
|
||
"resolvedSkills": append([]string(nil), routing.Skills...),
|
||
}
|
||
artifactRecord := buildArtifactRecord(sess, result, message)
|
||
if artifactRecord.RemoteWorkingDirectory != "" {
|
||
result["remoteWorkingDirectory"] = artifactRecord.RemoteWorkingDirectory
|
||
}
|
||
if artifactRecord.RemoteWorkspaceRefKind != "" {
|
||
result["remoteWorkspaceRefKind"] = artifactRecord.RemoteWorkspaceRefKind
|
||
}
|
||
if artifactRecord.ResultSummary != "" && strings.TrimSpace(shared.StringArg(result, "resultSummary", "")) == "" {
|
||
result["resultSummary"] = artifactRecord.ResultSummary
|
||
}
|
||
|
||
sess.mu.Lock()
|
||
sess.task.State = TaskStateFailed
|
||
sess.task.UpdatedAt = time.Now()
|
||
sess.lastResult = cloneMap(result)
|
||
sess.artifacts = artifactRecord
|
||
sess.mu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
func (o *SessionOrchestrator) runGateway(
|
||
ctx context.Context,
|
||
method string,
|
||
params map[string]any,
|
||
routing RoutingResult,
|
||
turnID string,
|
||
notify func(map[string]any),
|
||
) (map[string]any, *shared.RPCError) {
|
||
if o.server.gateway == nil {
|
||
return nil, &shared.RPCError{Code: -32001, Message: "GATEWAY_NOT_INITIALIZED"}
|
||
}
|
||
|
||
gatewayProvider := resolvedGatewayProviderID(params, routing)
|
||
if gatewayProvider == "" {
|
||
return nil, &shared.RPCError{Code: -32602, Message: "GATEWAY_PROVIDER_REQUIRED"}
|
||
}
|
||
params = withResolvedGatewayProvider(params, gatewayProvider)
|
||
if isOpenClawMode(gatewayProvider) && isSessionTaskMethod(method) {
|
||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID))
|
||
release, rpcErr := o.server.openClawGate.acquire(
|
||
ctx,
|
||
func(position int, queued int) {
|
||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||
"sessionId": sessionID,
|
||
"threadId": threadID,
|
||
"type": "status",
|
||
"event": "queued",
|
||
"message": "OpenClaw gateway task queued",
|
||
"pending": true,
|
||
"error": false,
|
||
"queue": map[string]any{
|
||
"position": position,
|
||
"queued": queued,
|
||
},
|
||
})
|
||
},
|
||
func(active int) {
|
||
o.server.emitSessionUpdate(notify, turnID, map[string]any{
|
||
"sessionId": sessionID,
|
||
"threadId": threadID,
|
||
"type": "status",
|
||
"event": "running",
|
||
"message": "OpenClaw gateway task admitted",
|
||
"pending": true,
|
||
"error": false,
|
||
"queue": map[string]any{
|
||
"active": active,
|
||
},
|
||
})
|
||
},
|
||
)
|
||
if rpcErr != nil {
|
||
return nil, rpcErr
|
||
}
|
||
if rpcErr := ensureProductionGatewayConnected(o.server, gatewayProvider, notify); rpcErr != nil {
|
||
release()
|
||
return nil, rpcErr
|
||
}
|
||
result, rpcErr := o.startOpenClawGatewayTask(ctx, params, routing, gatewayProvider, turnID, release, notify)
|
||
if rpcErr != nil {
|
||
release()
|
||
return nil, rpcErr
|
||
}
|
||
return result, nil
|
||
}
|
||
if rpcErr := ensureProductionGatewayConnected(o.server, gatewayProvider, notify); rpcErr != nil {
|
||
return nil, rpcErr
|
||
}
|
||
result := o.server.gateway.RequestByMode(
|
||
gatewayProvider,
|
||
method,
|
||
params,
|
||
2*time.Minute,
|
||
notify,
|
||
)
|
||
if !result.OK {
|
||
errMessage := strings.TrimSpace(shared.StringArg(result.Error, "message", "gateway execution failed"))
|
||
return nil, &shared.RPCError{Code: -32002, Message: errMessage}
|
||
}
|
||
payload := shared.AsMap(result.Payload)
|
||
if len(payload) == 0 {
|
||
payload = map[string]any{
|
||
"success": true,
|
||
"turnId": turnID,
|
||
"mode": router.ExecutionTargetGatewayChat,
|
||
}
|
||
}
|
||
if _, ok := payload["turnId"]; !ok {
|
||
payload["turnId"] = turnID
|
||
}
|
||
if _, ok := payload["mode"]; !ok {
|
||
payload["mode"] = router.ExecutionTargetGatewayChat
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
func (o *SessionOrchestrator) startOpenClawGatewayTask(
|
||
_ context.Context,
|
||
params map[string]any,
|
||
routing RoutingResult,
|
||
gatewayProvider string,
|
||
turnID string,
|
||
releaseAdmission func(),
|
||
notify func(map[string]any),
|
||
) (map[string]any, *shared.RPCError) {
|
||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID))
|
||
if sessionID == "" {
|
||
sessionID = threadID
|
||
}
|
||
notifyWithCollection := func(message map[string]any) {
|
||
if notify == nil {
|
||
return
|
||
}
|
||
if update := openClawGatewaySessionUpdate(message, sessionID, threadID, turnID); update != nil {
|
||
notify(update)
|
||
}
|
||
}
|
||
sessionKey := o.openClawSessionKey(params, turnID)
|
||
params = withOpenClawWritableWorkspace(params, openClawAppThreadKey(params))
|
||
artifactContract := openClawArtifactContractForParams(params, nil)
|
||
preparedArtifact, prepareErr := o.openClawArtifactPrepare(
|
||
gatewayProvider,
|
||
params,
|
||
sessionKey,
|
||
turnID,
|
||
artifactContract,
|
||
notifyWithCollection,
|
||
)
|
||
if prepareErr != nil {
|
||
return nil, prepareErr
|
||
}
|
||
logOpenClawArtifactSync(gatewayProvider, sessionKey, turnID, "prepare", true, false, false)
|
||
params = withOpenClawPreparedArtifactWorkspace(params, preparedArtifact)
|
||
chatParams, rpcErr := openClawChatSendParamsWithSessionKey(params, turnID, sessionKey)
|
||
if rpcErr != nil {
|
||
return nil, rpcErr
|
||
}
|
||
applyOpenClawPreparedArtifactToChatParams(chatParams, preparedArtifact, sessionKey, turnID, artifactContract)
|
||
chatSendTimeout := openClawAgentWaitTimeout(params, chatParams)
|
||
sendStarted := time.Now()
|
||
sendResult := o.openClawGatewayRequestWithRetry(
|
||
gatewayProvider,
|
||
"chat.send",
|
||
chatParams,
|
||
chatSendTimeout,
|
||
notifyWithCollection,
|
||
)
|
||
logOpenClawGatewayTiming(
|
||
gatewayProvider,
|
||
"chat.send",
|
||
sessionKey,
|
||
turnID,
|
||
time.Since(sendStarted),
|
||
sendResult.OK,
|
||
)
|
||
if !sendResult.OK {
|
||
return nil, gatewayRPCError(sendResult.Error, "openclaw chat.send failed")
|
||
}
|
||
sendPayload := shared.AsMap(sendResult.Payload)
|
||
if rpcErr := validateOpenClawAcceptedSessionKey(sendPayload, sessionKey); rpcErr != nil {
|
||
return nil, rpcErr
|
||
}
|
||
runID := strings.TrimSpace(shared.StringArg(sendPayload, "runId", turnID))
|
||
if runID != turnID {
|
||
preparedArtifact, prepareErr = o.openClawArtifactPrepare(
|
||
gatewayProvider,
|
||
params,
|
||
sessionKey,
|
||
runID,
|
||
artifactContract,
|
||
notifyWithCollection,
|
||
)
|
||
if prepareErr != nil {
|
||
return nil, prepareErr
|
||
}
|
||
logOpenClawArtifactSync(gatewayProvider, sessionKey, runID, "prepare", true, false, false)
|
||
applyOpenClawPreparedArtifactToChatParams(chatParams, preparedArtifact, sessionKey, runID, artifactContract)
|
||
}
|
||
taskLoadClass, runtimeBudgetMinutes := openClawTaskRuntimePolicy(params, chatParams, artifactContract)
|
||
startedAt := time.Now()
|
||
record := &OpenClawTaskRecord{
|
||
SessionID: sessionID,
|
||
ThreadID: threadID,
|
||
TurnID: turnID,
|
||
RunID: runID,
|
||
SessionKey: sessionKey,
|
||
GatewayProviderID: gatewayProvider,
|
||
TaskLoadClass: taskLoadClass,
|
||
RuntimeBudgetMinutes: runtimeBudgetMinutes,
|
||
StartedAt: startedAt,
|
||
DeadlineAt: startedAt.Add(time.Duration(runtimeBudgetMinutes) * time.Minute),
|
||
ProgressStage: "running",
|
||
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...),
|
||
}
|
||
sess := o.server.getOrCreateSession(sessionID, threadID)
|
||
sess.mu.Lock()
|
||
sess.task.RunID = runID
|
||
sess.task.SessionKey = sessionKey
|
||
sess.task.GatewayProviderID = gatewayProvider
|
||
sess.task.TaskLoadClass = taskLoadClass
|
||
sess.task.ArtifactScope = strings.TrimSpace(preparedArtifact.ArtifactScope)
|
||
sess.task.ArtifactDirectory = strings.TrimSpace(preparedArtifact.ArtifactDirectory)
|
||
sess.task.RuntimeBudgetMinutes = runtimeBudgetMinutes
|
||
sess.task.StartedAt = startedAt
|
||
sess.task.DeadlineAt = record.DeadlineAt
|
||
sess.task.ProgressStage = "running"
|
||
sess.task.ProgressMessage = "OpenClaw task accepted"
|
||
sess.openClaw = record
|
||
running := openClawRunningTaskResult(record)
|
||
sess.lastResult = cloneMap(running)
|
||
sess.mu.Unlock()
|
||
if releaseAdmission != nil {
|
||
releaseAdmission()
|
||
}
|
||
if notify != nil {
|
||
notify(shared.NotificationEnvelope("session.update", map[string]any{
|
||
"sessionId": sessionID,
|
||
"threadId": threadID,
|
||
"turnId": turnID,
|
||
"runId": runID,
|
||
"type": "status",
|
||
"event": "running",
|
||
"message": "OpenClaw task accepted",
|
||
"pending": true,
|
||
"error": false,
|
||
"status": string(TaskStateRunning),
|
||
"runtimeBudgetMinutes": runtimeBudgetMinutes,
|
||
"progress": running["progress"],
|
||
}))
|
||
}
|
||
return running, nil
|
||
}
|
||
|
||
func openClawGatewayCompletedResultUpdate(sessionID string, threadID string, turnID string, result map[string]any) map[string]any {
|
||
success := true
|
||
if value, ok := result["success"].(bool); ok {
|
||
success = value
|
||
}
|
||
update := map[string]any{
|
||
"sessionId": sessionID,
|
||
"threadId": threadID,
|
||
"turnId": turnID,
|
||
"type": "status",
|
||
"event": "completed",
|
||
"pending": false,
|
||
"error": !success,
|
||
"result": result,
|
||
}
|
||
if output := firstNonEmptyString(result, "output", "message", "summary", "text"); output != "" {
|
||
update["message"] = output
|
||
update["text"] = output
|
||
}
|
||
for _, key := range []string{
|
||
"success",
|
||
"status",
|
||
"code",
|
||
"artifacts",
|
||
"files",
|
||
"attachments",
|
||
"artifactWarnings",
|
||
"remoteWorkingDirectory",
|
||
"remoteWorkspaceRefKind",
|
||
"resolvedGatewayProviderId",
|
||
"mode",
|
||
"runId",
|
||
} {
|
||
if value, ok := result[key]; ok {
|
||
update[key] = value
|
||
}
|
||
}
|
||
return update
|
||
}
|
||
|
||
func logOpenClawGatewayTiming(
|
||
gatewayProvider string,
|
||
method string,
|
||
sessionKey string,
|
||
runID string,
|
||
duration time.Duration,
|
||
ok bool,
|
||
) {
|
||
log.Printf(
|
||
"level=info component=openclaw_gateway event=request_timing provider=%q method=%q sessionId=%q runId=%q durationMs=%d ok=%t",
|
||
gatewayProvider,
|
||
method,
|
||
sessionKey,
|
||
runID,
|
||
duration.Milliseconds(),
|
||
ok,
|
||
)
|
||
}
|
||
|
||
func logOpenClawArtifactSync(
|
||
gatewayProvider string,
|
||
sessionKey string,
|
||
runID string,
|
||
stage string,
|
||
prepared bool,
|
||
exported bool,
|
||
empty bool,
|
||
) {
|
||
log.Printf(
|
||
"level=info component=openclaw_gateway event=artifact_sync provider=%q sessionId=%q runId=%q stage=%q prepared=%t exported=%t empty=%t",
|
||
gatewayProvider,
|
||
sessionKey,
|
||
runID,
|
||
stage,
|
||
prepared,
|
||
exported,
|
||
empty,
|
||
)
|
||
}
|
||
|
||
func isSessionTaskMethod(method string) bool {
|
||
switch strings.TrimSpace(method) {
|
||
case "session.start", "session.message":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
type openClawPreparedArtifactScope struct {
|
||
RemoteWorkingDirectory string
|
||
RemoteWorkspaceRefKind string
|
||
ArtifactScope string
|
||
ArtifactDirectory string
|
||
RelativeArtifactDirectory string
|
||
ScopeKind string
|
||
}
|
||
|
||
func openClawPreparedArtifactScopeFromPayload(payload map[string]any) *openClawPreparedArtifactScope {
|
||
if payload == nil {
|
||
return nil
|
||
}
|
||
prepared := &openClawPreparedArtifactScope{
|
||
RemoteWorkingDirectory: strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", "")),
|
||
RemoteWorkspaceRefKind: strings.TrimSpace(shared.StringArg(payload, "remoteWorkspaceRefKind", "")),
|
||
ArtifactScope: strings.TrimSpace(shared.StringArg(payload, "artifactScope", "")),
|
||
ArtifactDirectory: strings.TrimSpace(shared.StringArg(payload, "artifactDirectory", "")),
|
||
RelativeArtifactDirectory: strings.TrimSpace(shared.StringArg(payload, "relativeArtifactDirectory", "")),
|
||
ScopeKind: strings.TrimSpace(shared.StringArg(payload, "scopeKind", "")),
|
||
}
|
||
if prepared.ArtifactScope == "" || prepared.ArtifactDirectory == "" {
|
||
return nil
|
||
}
|
||
if prepared.ScopeKind == "" {
|
||
prepared.ScopeKind = "task"
|
||
}
|
||
return prepared
|
||
}
|
||
|
||
func (o *SessionOrchestrator) openClawArtifactPrepare(
|
||
gatewayProvider string,
|
||
params map[string]any,
|
||
sessionKey string,
|
||
runID string,
|
||
artifactContract openClawArtifactContract,
|
||
notify func(map[string]any),
|
||
) (*openClawPreparedArtifactScope, *shared.RPCError) {
|
||
sessionKey = strings.TrimSpace(sessionKey)
|
||
runID = strings.TrimSpace(runID)
|
||
if sessionKey == "" || runID == "" {
|
||
log.Printf(
|
||
"level=warn component=openclaw_gateway event=artifact_prepare_missing_context provider=%q hasOpenClawSessionKey=%t hasRunId=%t appThreadKey=%q",
|
||
gatewayProvider,
|
||
sessionKey != "",
|
||
runID != "",
|
||
openClawAppThreadKey(params),
|
||
)
|
||
return nil, &shared.RPCError{Code: -32602, Message: "openclaw artifact prepare requires openclawSessionKey and runId"}
|
||
}
|
||
prepareParams := openClawSessionPrepareParams(params, sessionKey, runID, artifactContract)
|
||
log.Printf(
|
||
"level=info component=openclaw_gateway event=artifact_prepare_context provider=%q hasOpenClawSessionKey=%t hasRunId=%t hasWorkspaceDir=%t expectedArtifactDirs=%d",
|
||
gatewayProvider,
|
||
strings.TrimSpace(shared.StringArg(prepareParams, "openclawSessionKey", "")) != "",
|
||
strings.TrimSpace(shared.StringArg(prepareParams, "runId", "")) != "",
|
||
strings.TrimSpace(shared.StringArg(prepareParams, "workspaceDir", "")) != "",
|
||
len(shared.ListArg(prepareParams, "expectedArtifactDirs")),
|
||
)
|
||
prepareResult := o.openClawGatewayRequestWithRetry(
|
||
gatewayProvider,
|
||
"xworkmate.session.prepare",
|
||
prepareParams,
|
||
30*time.Second,
|
||
notify,
|
||
)
|
||
if !prepareResult.OK {
|
||
if isOpenClawUnknownMethodError(prepareResult.Error, "xworkmate.session.prepare") {
|
||
return openClawPreparedArtifactScopeFromPayload(openClawFallbackSessionPreparePayload(prepareParams)), nil
|
||
}
|
||
return nil, gatewayRPCError(prepareResult.Error, "openclaw artifact prepare failed")
|
||
}
|
||
prepared := openClawPreparedArtifactScopeFromPayload(shared.AsMap(prepareResult.Payload))
|
||
if prepared == nil {
|
||
return nil, &shared.RPCError{Code: -32002, Message: "openclaw artifact prepare returned no scoped artifact directory"}
|
||
}
|
||
return prepared, nil
|
||
}
|
||
|
||
func isOpenClawUnknownMethodError(errorPayload map[string]any, method string) bool {
|
||
message := strings.ToLower(strings.TrimSpace(shared.StringArg(errorPayload, "message", "")))
|
||
if message == "" {
|
||
return false
|
||
}
|
||
// 消息形如「unknown method: <method>」已明确指向「网关不认识该方法」,足以判定,
|
||
// 据此走 graceful fallback(如 openClawFallbackSessionPreparePayload)。
|
||
//
|
||
// 注意:不能再用严格的 code 白名单来 gate。真实网关常以数字 JSON-RPC code
|
||
// (-32601 method not found / -32600 invalid request / -32002 等) 回传,
|
||
// 经 shared.StringArg(fmt.Sprint) 会被字符串化为 "-32601"/"-32002",
|
||
// 旧实现只接受 {"", INVALID_REQUEST, METHOD_NOT_FOUND},导致 fallback 失效、
|
||
// session.prepare 直接以 -32002 硬失败整轮任务。
|
||
return strings.Contains(message, "unknown method") &&
|
||
strings.Contains(message, strings.ToLower(strings.TrimSpace(method)))
|
||
}
|
||
|
||
func openClawFallbackSessionPreparePayload(params map[string]any) map[string]any {
|
||
sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||
if sessionKey == "" {
|
||
sessionKey = strings.TrimSpace(shared.StringArg(params, "sessionKey", ""))
|
||
}
|
||
if sessionKey == "" {
|
||
sessionKey = "main"
|
||
}
|
||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||
if runID == "" {
|
||
runID = strings.TrimSpace(shared.StringArg(params, "taskId", ""))
|
||
}
|
||
if runID == "" {
|
||
runID = strings.TrimSpace(shared.StringArg(params, "requestId", ""))
|
||
}
|
||
if runID == "" {
|
||
runID = "default"
|
||
}
|
||
relativeArtifactDirectory := filepath.Join("tasks", sessionKey, runID)
|
||
workspaceDir := openClawArtifactWorkspaceDir(params)
|
||
artifactDirectory := filepath.Join(workspaceDir, relativeArtifactDirectory)
|
||
return map[string]any{
|
||
"ok": true,
|
||
"fallback": true,
|
||
"compatibilityMode": "local-session-prepare",
|
||
"runId": runID,
|
||
"sessionKey": sessionKey,
|
||
"openclawSessionKey": sessionKey,
|
||
"remoteWorkingDirectory": workspaceDir,
|
||
"remoteWorkspaceRefKind": "path",
|
||
"artifactScope": relativeArtifactDirectory,
|
||
"artifactDirectory": artifactDirectory,
|
||
"relativeArtifactDirectory": relativeArtifactDirectory,
|
||
"scopeKind": "task",
|
||
}
|
||
}
|
||
|
||
func openClawSessionPrepareParams(params map[string]any, openClawSessionKey string, runID string, artifactContract openClawArtifactContract) map[string]any {
|
||
appThreadKey := openClawAppThreadKey(params)
|
||
result := map[string]any{
|
||
"schemaVersion": 1,
|
||
"appThreadKey": appThreadKey,
|
||
"openclawSessionKey": strings.TrimSpace(openClawSessionKey),
|
||
"runId": strings.TrimSpace(runID),
|
||
"requestId": strings.TrimSpace(runID),
|
||
"externalTaskId": strings.TrimSpace(runID),
|
||
}
|
||
if len(artifactContract.ExpectedArtifactDirs) > 0 {
|
||
result["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
|
||
}
|
||
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
|
||
}
|
||
return result
|
||
}
|
||
|
||
func openClawArtifactWorkspaceDir(params map[string]any) string {
|
||
if value := strings.TrimSpace(shared.StringArg(params, "workspaceDir", "")); value != "" {
|
||
return value
|
||
}
|
||
for _, key := range []string{"remoteWorkingDirectoryHint", "remoteWorkingDirectory", "workingDirectory"} {
|
||
if value := strings.TrimSpace(shared.StringArg(params, key, "")); isOpenClawWorkspacePath(value) {
|
||
return value
|
||
}
|
||
}
|
||
if configured := strings.TrimSpace(os.Getenv("OPENCLAW_WORKSPACE_DIR")); configured != "" {
|
||
return configured
|
||
}
|
||
return "~/.openclaw/workspace"
|
||
}
|
||
|
||
func isOpenClawWorkspacePath(path string) bool {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return false
|
||
}
|
||
normalized := filepath.ToSlash(filepath.Clean(path))
|
||
return strings.HasPrefix(normalized, "/home/ubuntu/.openclaw/workspace") ||
|
||
strings.HasPrefix(normalized, "/Users/") && strings.Contains(normalized, "/.openclaw/workspace") ||
|
||
strings.HasPrefix(normalized, "~/.openclaw/workspace") ||
|
||
strings.HasPrefix(normalized, "$HOME/.openclaw/workspace")
|
||
}
|
||
|
||
func openClawAppThreadKey(params map[string]any) string {
|
||
if value := strings.TrimSpace(shared.StringArg(params, "appThreadKey", "")); value != "" {
|
||
return value
|
||
}
|
||
metadata := shared.AsMap(params["metadata"])
|
||
for _, key := range []string{"appThreadKey"} {
|
||
if value := strings.TrimSpace(shared.StringArg(metadata, key, "")); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
contract := shared.AsMap(metadata["xworkmateTaskArtifactContract"])
|
||
if value := strings.TrimSpace(shared.StringArg(contract, "appThreadKey", "")); value != "" {
|
||
return value
|
||
}
|
||
for _, key := range []string{"threadId", "sessionId"} {
|
||
if value := strings.TrimSpace(shared.StringArg(params, key, "")); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return "main"
|
||
}
|
||
|
||
func applyOpenClawPreparedArtifactToResult(result map[string]any, prepared *openClawPreparedArtifactScope) {
|
||
if result == nil || prepared == nil {
|
||
return
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", "")) == "" && prepared.RemoteWorkingDirectory != "" {
|
||
result["remoteWorkingDirectory"] = prepared.RemoteWorkingDirectory
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "remoteWorkspaceRefKind", "")) == "" && prepared.RemoteWorkspaceRefKind != "" {
|
||
result["remoteWorkspaceRefKind"] = prepared.RemoteWorkspaceRefKind
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "artifactScope", "")) == "" {
|
||
result["artifactScope"] = prepared.ArtifactScope
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "artifactDirectory", "")) == "" {
|
||
result["artifactDirectory"] = prepared.ArtifactDirectory
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "relativeArtifactDirectory", "")) == "" && prepared.RelativeArtifactDirectory != "" {
|
||
result["relativeArtifactDirectory"] = prepared.RelativeArtifactDirectory
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "scopeKind", "")) == "" {
|
||
result["scopeKind"] = prepared.ScopeKind
|
||
}
|
||
}
|
||
|
||
func applyOpenClawPreparedArtifactToChatParams(
|
||
chatParams map[string]any,
|
||
prepared *openClawPreparedArtifactScope,
|
||
sessionKey string,
|
||
runID string,
|
||
contract openClawArtifactContract,
|
||
) {
|
||
if chatParams == nil || prepared == nil || strings.TrimSpace(prepared.ArtifactDirectory) == "" {
|
||
return
|
||
}
|
||
receipt := openClawArtifactSystemProvenanceReceipt(prepared, sessionKey, runID, contract)
|
||
if receipt == "" {
|
||
return
|
||
}
|
||
existing := strings.TrimSpace(shared.StringArg(chatParams, "systemProvenanceReceipt", ""))
|
||
if existing != "" {
|
||
chatParams["systemProvenanceReceipt"] = existing + "\n\n" + receipt
|
||
return
|
||
}
|
||
chatParams["systemProvenanceReceipt"] = receipt
|
||
}
|
||
|
||
func openClawArtifactSystemProvenanceReceipt(
|
||
prepared *openClawPreparedArtifactScope,
|
||
sessionKey string,
|
||
runID string,
|
||
contract openClawArtifactContract,
|
||
) string {
|
||
if prepared == nil {
|
||
return ""
|
||
}
|
||
artifactDirectory := strings.TrimSpace(prepared.ArtifactDirectory)
|
||
artifactScope := strings.TrimSpace(prepared.ArtifactScope)
|
||
if artifactDirectory == "" || artifactScope == "" {
|
||
return ""
|
||
}
|
||
lines := []string{
|
||
"XWorkmate task artifact context:",
|
||
"- Treat artifactDirectory as the working directory for all files generated in this turn.",
|
||
"- Write final artifacts directly under artifactDirectory using relative paths such as assets/images/... or prompts/....",
|
||
"- Do not create a nested task_artifacts/<session> directory inside artifactDirectory.",
|
||
"- Environment contract for shell commands:",
|
||
" export XWORKMATE_TASK_ARTIFACT_DIR=" + shellSingleQuote(artifactDirectory),
|
||
" export XWORKMATE_ARTIFACT_DIRECTORY=" + shellSingleQuote(artifactDirectory),
|
||
" export XWORKMATE_ARTIFACT_SCOPE=" + shellSingleQuote(artifactScope),
|
||
" export XWORKMATE_SESSION_KEY=" + shellSingleQuote(strings.TrimSpace(sessionKey)),
|
||
" export XWORKMATE_RUN_ID=" + shellSingleQuote(strings.TrimSpace(runID)),
|
||
" cd " + shellSingleQuote(artifactDirectory),
|
||
"artifactDirectory: " + artifactDirectory,
|
||
"artifactScope: " + artifactScope,
|
||
}
|
||
if relative := strings.TrimSpace(prepared.RelativeArtifactDirectory); relative != "" {
|
||
lines = append(lines, "relativeArtifactDirectory: "+relative)
|
||
}
|
||
if remote := strings.TrimSpace(prepared.RemoteWorkingDirectory); remote != "" {
|
||
lines = append(lines, "remoteWorkingDirectory: "+remote)
|
||
}
|
||
if contract.ComplexLongChain {
|
||
lines = append(lines,
|
||
"",
|
||
"Complex XWorkmate artifact contract:",
|
||
"- Persist the stage plan, intermediate outputs, generated assets, final manifest, and final deliverables inside artifactDirectory.",
|
||
"- Use clear relative paths that match the task structure instead of global cache or temporary directories.",
|
||
"- Do not report completion until requested final deliverables are present in artifactDirectory.",
|
||
)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func shellSingleQuote(value string) string {
|
||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||
}
|
||
|
||
type openClawArtifactContract struct {
|
||
TaskLoadClass string
|
||
ComplexLongChain bool
|
||
RequiresArtifactExport bool
|
||
ExpectedArtifactDirs []string
|
||
RequiredArtifactExts []string
|
||
ExpectedFileCounts map[string]int
|
||
SourceMessage string
|
||
}
|
||
|
||
func openClawArtifactContractForParams(params map[string]any, chatParams map[string]any) openClawArtifactContract {
|
||
metadata := shared.AsMap(params["metadata"])
|
||
taskLoadClass := strings.TrimSpace(shared.StringArg(metadata, "taskLoadClass", ""))
|
||
message := strings.TrimSpace(shared.StringArg(chatParams, "message", ""))
|
||
if message == "" {
|
||
message = openClawCurrentTurnMessage(params)
|
||
}
|
||
lowerMessage := strings.ToLower(message)
|
||
contract := shared.AsMap(metadata["xworkmateTaskArtifactContract"])
|
||
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)
|
||
}
|
||
expectedFileCounts := normalizeOpenClawArtifactExtCountMap(shared.AsMap(contract["expectedFileCountByExtension"]))
|
||
if len(expectedFileCounts) == 0 {
|
||
expectedFileCounts = normalizeOpenClawArtifactExtCountMap(shared.AsMap(metadata["expectedFileCountByExtension"]))
|
||
}
|
||
if len(expectedFileCounts) == 0 {
|
||
expectedFileCounts = normalizeOpenClawArtifactExtCountMap(shared.AsMap(shared.AsMap(metadata["xworkmateArtifactConstraints"])["expectedFileCountByExtension"]))
|
||
}
|
||
return openClawArtifactContract{
|
||
TaskLoadClass: taskLoadClass,
|
||
ComplexLongChain: complex,
|
||
RequiresArtifactExport: requiresExport,
|
||
ExpectedArtifactDirs: expectedDirs,
|
||
RequiredArtifactExts: requiredExts,
|
||
ExpectedFileCounts: expectedFileCounts,
|
||
SourceMessage: message,
|
||
}
|
||
}
|
||
|
||
func normalizeOpenClawArtifactExtCountMap(values map[string]any) map[string]int {
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
result := map[string]int{}
|
||
for key, raw := range values {
|
||
ext := strings.ToLower(strings.TrimSpace(key))
|
||
ext = strings.TrimPrefix(ext, ".")
|
||
if ext == "" || strings.Contains(ext, "/") || strings.Contains(ext, "\\") {
|
||
continue
|
||
}
|
||
count := openClawPositiveInt(raw)
|
||
if count <= 0 {
|
||
continue
|
||
}
|
||
result[ext] = count
|
||
}
|
||
if len(result) == 0 {
|
||
return nil
|
||
}
|
||
return result
|
||
}
|
||
|
||
func openClawPositiveInt(value any) int {
|
||
switch v := value.(type) {
|
||
case int:
|
||
return v
|
||
case int64:
|
||
return int(v)
|
||
case float64:
|
||
return int(v)
|
||
case float32:
|
||
return int(v)
|
||
case string:
|
||
var parsed int
|
||
if _, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &parsed); err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func normalizeOpenClawDirList(values []any) []string {
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
result := make([]string, 0, len(values))
|
||
seen := map[string]bool{}
|
||
for _, value := range values {
|
||
dir := strings.TrimSpace(fmt.Sprint(value))
|
||
if dir == "" || seen[dir] {
|
||
continue
|
||
}
|
||
seen[dir] = true
|
||
result = append(result, dir)
|
||
}
|
||
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,
|
||
) (map[string]any, *shared.RPCError) {
|
||
return openClawChatSendParamsWithSessionKey(params, turnID, openClawAgentMainSessionKey(openClawAppThreadKey(params)))
|
||
}
|
||
|
||
func openClawChatSendParamsWithSessionKey(
|
||
params map[string]any,
|
||
turnID string,
|
||
sessionKey string,
|
||
) (map[string]any, *shared.RPCError) {
|
||
message := openClawCurrentTurnMessage(params)
|
||
if message == "" {
|
||
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_TASK_PROMPT_REQUIRED"}
|
||
}
|
||
chatParams := map[string]any{
|
||
"sessionKey": sessionKey,
|
||
"message": message,
|
||
"idempotencyKey": turnID,
|
||
}
|
||
attachments := openClawNonEmptyPathAttachments(params)
|
||
inlineAttachments, rpcErr := materializeOpenClawInlineAttachments(params, turnID)
|
||
if rpcErr != nil {
|
||
return nil, rpcErr
|
||
}
|
||
attachments = append(attachments, inlineAttachments...)
|
||
if len(attachments) > 0 {
|
||
chatParams["message"] = shared.AugmentPromptWithAttachments(
|
||
message,
|
||
map[string]any{"attachments": attachments},
|
||
)
|
||
}
|
||
if thinking := strings.TrimSpace(shared.StringArg(params, "thinking", "")); thinking != "" {
|
||
chatParams["thinking"] = thinking
|
||
}
|
||
return chatParams, nil
|
||
}
|
||
|
||
func withOpenClawWritableWorkspace(params map[string]any, appThreadKey string) map[string]any {
|
||
workingDirectory := strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
|
||
remoteHint := strings.TrimSpace(shared.StringArg(params, "remoteWorkingDirectoryHint", ""))
|
||
ownerScoped := firstOwnerScopedWorkspace(workingDirectory, remoteHint)
|
||
if ownerScoped == "" {
|
||
return params
|
||
}
|
||
writable := openClawWritableWorkspaceForOwnerPath(ownerScoped, appThreadKey)
|
||
if writable == "" || writable == ownerScoped {
|
||
return params
|
||
}
|
||
next := make(map[string]any, len(params)+1)
|
||
for key, value := range params {
|
||
next[key] = value
|
||
}
|
||
if workingDirectory == ownerScoped {
|
||
next["workingDirectory"] = writable
|
||
}
|
||
if remoteHint == ownerScoped {
|
||
next["remoteWorkingDirectoryHint"] = writable
|
||
}
|
||
for _, key := range []string{"taskPrompt", "prompt", "message"} {
|
||
if value, ok := next[key].(string); ok && strings.Contains(value, ownerScoped) {
|
||
next[key] = strings.ReplaceAll(value, ownerScoped, writable)
|
||
}
|
||
}
|
||
return next
|
||
}
|
||
|
||
func withOpenClawPreparedArtifactWorkspace(params map[string]any, prepared *openClawPreparedArtifactScope) map[string]any {
|
||
if prepared == nil {
|
||
return params
|
||
}
|
||
artifactDirectory := strings.TrimSpace(prepared.ArtifactDirectory)
|
||
if artifactDirectory == "" {
|
||
return params
|
||
}
|
||
replacements := openClawWorkspacePromptReplacementValues(params)
|
||
next := make(map[string]any, len(params)+2)
|
||
for key, value := range params {
|
||
next[key] = value
|
||
}
|
||
next["workingDirectory"] = artifactDirectory
|
||
next["remoteWorkingDirectoryHint"] = artifactDirectory
|
||
for _, key := range []string{"taskPrompt", "prompt", "message"} {
|
||
value, ok := next[key].(string)
|
||
if !ok || strings.TrimSpace(value) == "" {
|
||
continue
|
||
}
|
||
next[key] = rewriteOpenClawWorkspaceReferences(value, artifactDirectory, replacements)
|
||
}
|
||
return next
|
||
}
|
||
|
||
func openClawWorkspacePromptReplacementValues(params map[string]any) []string {
|
||
values := []string{
|
||
shared.StringArg(params, "workingDirectory", ""),
|
||
shared.StringArg(params, "remoteWorkingDirectoryHint", ""),
|
||
shared.StringArg(params, "remoteWorkingDirectory", ""),
|
||
}
|
||
metadata := shared.AsMap(params["metadata"])
|
||
contract := shared.AsMap(metadata["xworkmateTaskArtifactContract"])
|
||
values = append(values,
|
||
shared.StringArg(contract, "currentTaskWorkspace", ""),
|
||
shared.StringArg(contract, "remoteWorkspaceHint", ""),
|
||
)
|
||
result := make([]string, 0, len(values))
|
||
seen := map[string]bool{}
|
||
for _, value := range values {
|
||
trimmed := strings.TrimSpace(value)
|
||
if trimmed == "" || seen[trimmed] {
|
||
continue
|
||
}
|
||
seen[trimmed] = true
|
||
result = append(result, trimmed)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func rewriteOpenClawWorkspaceReferences(message string, artifactDirectory string, replacements []string) string {
|
||
result := message
|
||
for _, value := range replacements {
|
||
if value != artifactDirectory {
|
||
result = strings.ReplaceAll(result, value, artifactDirectory)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func firstOwnerScopedWorkspace(values ...string) string {
|
||
for _, value := range values {
|
||
trimmed := strings.TrimSpace(value)
|
||
if strings.HasPrefix(filepath.Clean(trimmed), "/owners/") {
|
||
return trimmed
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func openClawWritableWorkspaceForOwnerPath(ownerPath string, sessionKey string) string {
|
||
root := strings.TrimSpace(os.Getenv("OPENCLAW_WRITABLE_WORKSPACE_ROOT"))
|
||
if root == "" {
|
||
return ""
|
||
}
|
||
root = strings.TrimRight(filepath.Clean(root), string(os.PathSeparator))
|
||
if root == "" || root == "." || root == string(os.PathSeparator) {
|
||
return ""
|
||
}
|
||
leaf := safeOpenClawAttachmentPathSegment(sessionKey, "task")
|
||
if leaf == "task" {
|
||
leaf = safeOpenClawAttachmentPathSegment(filepath.Base(filepath.Clean(ownerPath)), "task")
|
||
}
|
||
return filepath.Join(root, leaf)
|
||
}
|
||
|
||
func openClawNonEmptyPathAttachments(params map[string]any) []any {
|
||
rawAttachments := shared.ListArg(params, "attachments")
|
||
if len(rawAttachments) == 0 {
|
||
return nil
|
||
}
|
||
inlineAttachmentNames := map[string]bool{}
|
||
for _, raw := range shared.ListArg(params, "inlineAttachments") {
|
||
name := strings.TrimSpace(shared.StringArg(shared.AsMap(raw), "name", ""))
|
||
if name != "" {
|
||
inlineAttachmentNames[name] = true
|
||
}
|
||
}
|
||
attachments := make([]any, 0, len(rawAttachments))
|
||
for _, raw := range rawAttachments {
|
||
attachment := shared.AsMap(raw)
|
||
if len(attachment) == 0 {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(shared.StringArg(attachment, "name", "attachment"))
|
||
if inlineAttachmentNames[name] {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(attachment, "path", "")) == "" {
|
||
continue
|
||
}
|
||
attachments = append(attachments, map[string]any{
|
||
"name": name,
|
||
"description": strings.TrimSpace(shared.StringArg(attachment, "description", "")),
|
||
"path": strings.TrimSpace(shared.StringArg(attachment, "path", "")),
|
||
})
|
||
}
|
||
return attachments
|
||
}
|
||
|
||
func materializeOpenClawInlineAttachments(params map[string]any, turnID string) ([]any, *shared.RPCError) {
|
||
rawAttachments := shared.ListArg(params, "inlineAttachments")
|
||
if len(rawAttachments) == 0 {
|
||
return nil, nil
|
||
}
|
||
workingDirectory := strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
|
||
if workingDirectory == "" {
|
||
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_ATTACHMENT_WORKING_DIRECTORY_REQUIRED"}
|
||
}
|
||
workingDirectory = openClawAttachmentWorkingDirectory(params, workingDirectory)
|
||
attachmentDirectory := filepath.Join(
|
||
workingDirectory,
|
||
".xworkmate",
|
||
"attachments",
|
||
safeOpenClawAttachmentPathSegment(turnID, "turn"),
|
||
)
|
||
if err := os.MkdirAll(attachmentDirectory, 0o755); err != nil {
|
||
return nil, &shared.RPCError{Code: -32002, Message: "OPENCLAW_ATTACHMENT_DIRECTORY_FAILED: " + err.Error()}
|
||
}
|
||
attachments := make([]any, 0, len(rawAttachments))
|
||
totalBytes := 0
|
||
for index, raw := range rawAttachments {
|
||
attachment := shared.AsMap(raw)
|
||
if len(attachment) == 0 {
|
||
continue
|
||
}
|
||
name := safeOpenClawAttachmentFileName(
|
||
shared.StringArg(attachment, "name", shared.StringArg(attachment, "fileName", "attachment")),
|
||
)
|
||
mimeType := strings.TrimSpace(shared.StringArg(attachment, "mimeType", shared.StringArg(attachment, "description", "")))
|
||
content := strings.TrimSpace(shared.StringArg(attachment, "content", ""))
|
||
if content == "" {
|
||
continue
|
||
}
|
||
bytes, err := decodeOpenClawInlineAttachmentContent(content)
|
||
if err != nil {
|
||
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_ATTACHMENT_INVALID_BASE64: " + name}
|
||
}
|
||
if len(bytes) > openClawInlineAttachmentMaxFileBytes {
|
||
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_ATTACHMENT_FILE_TOO_LARGE: " + name}
|
||
}
|
||
if totalBytes+len(bytes) > openClawInlineAttachmentMaxTotalBytes {
|
||
return nil, &shared.RPCError{Code: -32602, Message: "OPENCLAW_ATTACHMENT_TOTAL_TOO_LARGE"}
|
||
}
|
||
totalBytes += len(bytes)
|
||
path := filepath.Join(attachmentDirectory, fmt.Sprintf("%02d-%s", index+1, name))
|
||
if err := os.WriteFile(path, bytes, 0o600); err != nil {
|
||
return nil, &shared.RPCError{Code: -32002, Message: "OPENCLAW_ATTACHMENT_WRITE_FAILED: " + err.Error()}
|
||
}
|
||
attachments = append(attachments, map[string]any{
|
||
"name": name,
|
||
"description": mimeType,
|
||
"path": path,
|
||
"sizeBytes": len(bytes),
|
||
"source": "inlineAttachment",
|
||
})
|
||
}
|
||
return attachments, nil
|
||
}
|
||
|
||
func openClawAttachmentWorkingDirectory(params map[string]any, workingDirectory string) string {
|
||
candidate := strings.TrimSpace(workingDirectory)
|
||
remoteHint := strings.TrimSpace(shared.StringArg(params, "remoteWorkingDirectoryHint", ""))
|
||
if remoteHint == "" || remoteHint == candidate {
|
||
return candidate
|
||
}
|
||
if isDesktopLocalWorkspacePath(candidate) {
|
||
return remoteHint
|
||
}
|
||
return candidate
|
||
}
|
||
|
||
func isDesktopLocalWorkspacePath(path string) bool {
|
||
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||
return strings.HasPrefix(cleaned, "/Users/") ||
|
||
strings.HasPrefix(cleaned, "/Volumes/")
|
||
}
|
||
|
||
func decodeOpenClawInlineAttachmentContent(content string) ([]byte, error) {
|
||
normalized := strings.TrimSpace(content)
|
||
if comma := strings.LastIndex(normalized, ","); comma >= 0 {
|
||
normalized = strings.TrimSpace(normalized[comma+1:])
|
||
}
|
||
return base64.StdEncoding.DecodeString(normalized)
|
||
}
|
||
|
||
func safeOpenClawAttachmentPathSegment(value string, fallback string) string {
|
||
trimmed := strings.TrimSpace(value)
|
||
if trimmed == "" {
|
||
trimmed = fallback
|
||
}
|
||
var builder strings.Builder
|
||
for _, r := range trimmed {
|
||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||
builder.WriteRune(r)
|
||
} else {
|
||
builder.WriteByte('-')
|
||
}
|
||
}
|
||
result := strings.Trim(builder.String(), "-")
|
||
if result == "" {
|
||
return fallback
|
||
}
|
||
return result
|
||
}
|
||
|
||
func safeOpenClawAttachmentFileName(value string) string {
|
||
name := strings.TrimSpace(filepath.Base(value))
|
||
if name == "." || name == string(os.PathSeparator) || name == "" {
|
||
name = "attachment"
|
||
}
|
||
name = strings.ReplaceAll(name, string(os.PathSeparator), "-")
|
||
name = strings.ReplaceAll(name, "/", "-")
|
||
name = strings.ReplaceAll(name, "\\", "-")
|
||
return name
|
||
}
|
||
|
||
func openClawAgentWaitTimeout(params map[string]any, chatParams map[string]any) time.Duration {
|
||
message := strings.TrimSpace(shared.StringArg(chatParams, "message", ""))
|
||
if message == "" {
|
||
message = openClawCurrentTurnMessage(params)
|
||
}
|
||
timeout := openClawAgentWaitDefaultTimeout
|
||
|
||
lowerMessage := strings.ToLower(message)
|
||
if strings.TrimSpace(shared.StringArg(shared.AsMap(params["metadata"]), "taskLoadClass", "")) == "complex_long_chain_task" ||
|
||
isOpenClawLongArtifactTask(lowerMessage) {
|
||
return openClawAgentWaitMaxTimeout
|
||
}
|
||
for _, keyword := range []string{
|
||
"video",
|
||
"mp4",
|
||
"hyperframes",
|
||
"remotion",
|
||
"ffmpeg",
|
||
"render",
|
||
"视频",
|
||
"渲染",
|
||
"口播",
|
||
"字幕",
|
||
} {
|
||
if strings.Contains(lowerMessage, keyword) {
|
||
timeout += 8 * time.Minute
|
||
break
|
||
}
|
||
}
|
||
if strings.Contains(lowerMessage, "it-infra-evolution-video") ||
|
||
strings.Contains(lowerMessage, "ai-tech-news-video") ||
|
||
strings.Contains(lowerMessage, "product-intro-video") {
|
||
timeout += 4 * time.Minute
|
||
}
|
||
if len([]rune(message)) > 1200 {
|
||
timeout += 2 * time.Minute
|
||
}
|
||
if attachments := shared.ListArg(params, "attachments"); len(attachments) > 0 {
|
||
timeout += time.Duration(min(len(attachments), 6)) * time.Minute
|
||
}
|
||
if timeout > openClawAgentWaitMaxTimeout {
|
||
return openClawAgentWaitMaxTimeout
|
||
}
|
||
return timeout
|
||
}
|
||
|
||
func isOpenClawLongArtifactTask(message string) bool {
|
||
hasDocumentOutput := openClawMessageContainsAny(message, []string{
|
||
"pdf",
|
||
"ppt",
|
||
"pptx",
|
||
"powerpoint",
|
||
"docx",
|
||
"document",
|
||
"文档",
|
||
"文件",
|
||
"输出",
|
||
"导出",
|
||
})
|
||
hasImageWork := openClawMessageContainsAny(message, []string{
|
||
"gpt images",
|
||
"gpt-images",
|
||
"images2",
|
||
"image",
|
||
"images",
|
||
"illustration",
|
||
"插图",
|
||
"配图",
|
||
"图片",
|
||
"生成图",
|
||
})
|
||
hasMultiStageWork := openClawMessageContainsAny(message, []string{
|
||
"chapter",
|
||
"section",
|
||
"codex",
|
||
"artifact export",
|
||
"章节",
|
||
"每章",
|
||
"拆分",
|
||
"多章节",
|
||
"汇总",
|
||
"排版",
|
||
})
|
||
return hasDocumentOutput && hasImageWork && hasMultiStageWork
|
||
}
|
||
|
||
func openClawMessageContainsAny(message string, keywords []string) bool {
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(message, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func openClawCurrentTurnMessage(params map[string]any) string {
|
||
if params == nil {
|
||
return ""
|
||
}
|
||
for _, key := range []string{"taskPrompt", "prompt", "message"} {
|
||
if text := strings.TrimSpace(strings.Join(openClawTextFragments(params[key]), "\n")); text != "" {
|
||
return text
|
||
}
|
||
}
|
||
if text := strings.TrimSpace(strings.Join(openClawLatestUserMessageText(params["messages"]), "\n")); text != "" {
|
||
return text
|
||
}
|
||
for _, key := range []string{"input", "content"} {
|
||
if text := strings.TrimSpace(strings.Join(openClawTextFragments(params[key]), "\n")); text != "" {
|
||
return text
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func openClawLatestUserMessageText(raw any) []string {
|
||
messages, ok := raw.([]any)
|
||
if !ok || len(messages) == 0 {
|
||
return nil
|
||
}
|
||
var fallback []string
|
||
for index := len(messages) - 1; index >= 0; index-- {
|
||
message := shared.AsMap(messages[index])
|
||
if len(message) == 0 {
|
||
continue
|
||
}
|
||
text := compactOpenClawTexts(openClawMessageText(message))
|
||
if len(text) == 0 {
|
||
continue
|
||
}
|
||
if fallback == nil {
|
||
fallback = text
|
||
}
|
||
role := strings.ToLower(strings.TrimSpace(shared.StringArg(message, "role", "")))
|
||
switch role {
|
||
case "user", "human", "client":
|
||
return text
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func openClawMessageText(message map[string]any) []string {
|
||
if len(message) == 0 {
|
||
return nil
|
||
}
|
||
texts := make([]string, 0, 4)
|
||
for _, key := range []string{"content", "parts", "text", "message"} {
|
||
texts = append(texts, openClawTextFragments(message[key])...)
|
||
}
|
||
return texts
|
||
}
|
||
|
||
func openClawTextFragments(raw any) []string {
|
||
switch value := raw.(type) {
|
||
case nil:
|
||
return nil
|
||
case string:
|
||
if text := strings.TrimSpace(value); text != "" {
|
||
return []string{text}
|
||
}
|
||
case []any:
|
||
texts := make([]string, 0, len(value))
|
||
for _, item := range value {
|
||
texts = append(texts, openClawTextFragments(item)...)
|
||
}
|
||
return compactOpenClawTexts(texts)
|
||
case map[string]any:
|
||
texts := make([]string, 0, len(value))
|
||
for _, key := range []string{"text", "content", "message", "value"} {
|
||
texts = append(texts, openClawTextFragments(value[key])...)
|
||
}
|
||
return compactOpenClawTexts(texts)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func compactOpenClawTexts(texts []string) []string {
|
||
if len(texts) == 0 {
|
||
return nil
|
||
}
|
||
result := make([]string, 0, len(texts))
|
||
for _, text := range texts {
|
||
if trimmed := strings.TrimSpace(text); trimmed != "" {
|
||
result = append(result, trimmed)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (o *SessionOrchestrator) openClawSessionKey(params map[string]any, turnID string) string {
|
||
if explicit := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "")); explicit != "" {
|
||
return explicit
|
||
}
|
||
return openClawAgentMainSessionKey(openClawAppThreadKey(params))
|
||
}
|
||
|
||
func openClawAgentMainSessionKey(appThreadKey string) string {
|
||
appThreadKey = strings.TrimSpace(appThreadKey)
|
||
if appThreadKey == "" {
|
||
appThreadKey = "main"
|
||
}
|
||
return "agent:main:" + appThreadKey
|
||
}
|
||
|
||
func validateOpenClawAcceptedSessionKey(payload map[string]any, expectedSessionKey string) *shared.RPCError {
|
||
actual := strings.TrimSpace(shared.StringArg(payload, "sessionKey", ""))
|
||
expected := strings.TrimSpace(expectedSessionKey)
|
||
if actual == "" || expected == "" || actual == expected {
|
||
return nil
|
||
}
|
||
return &shared.RPCError{
|
||
Code: -32002,
|
||
Message: fmt.Sprintf(
|
||
"OPENCLAW_SESSION_MISMATCH: expected %s but OpenClaw accepted %s",
|
||
expected,
|
||
actual,
|
||
),
|
||
Data: map[string]any{
|
||
"code": "OPENCLAW_SESSION_MISMATCH",
|
||
"expectedSessionKey": expected,
|
||
"acceptedSessionKey": actual,
|
||
"expectedOpenClawKey": expected,
|
||
"actualOpenClawKey": actual,
|
||
},
|
||
}
|
||
}
|
||
|
||
func (o *SessionOrchestrator) openClawArtifactExport(
|
||
gatewayProvider string,
|
||
chatParams map[string]any,
|
||
artifactContract openClawArtifactContract,
|
||
runID string,
|
||
sinceUnixMs int64,
|
||
preparedArtifact *openClawPreparedArtifactScope,
|
||
notify func(map[string]any),
|
||
) map[string]any {
|
||
sessionKey := strings.TrimSpace(shared.StringArg(chatParams, "sessionKey", ""))
|
||
if sessionKey == "" || strings.TrimSpace(runID) == "" {
|
||
return nil
|
||
}
|
||
exportParams := map[string]any{
|
||
"openclawSessionKey": sessionKey,
|
||
"runId": strings.TrimSpace(runID),
|
||
"sinceUnixMs": sinceUnixMs,
|
||
"maxFiles": 64,
|
||
"maxInlineBytes": 0,
|
||
"includeContent": false,
|
||
}
|
||
if preparedArtifact != nil && strings.TrimSpace(preparedArtifact.ArtifactScope) != "" {
|
||
exportParams["artifactScope"] = strings.TrimSpace(preparedArtifact.ArtifactScope)
|
||
}
|
||
if len(artifactContract.ExpectedArtifactDirs) > 0 {
|
||
exportParams["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
|
||
}
|
||
if len(artifactContract.RequiredArtifactExts) > 0 {
|
||
exportParams["requiredArtifactExtensions"] = append([]string(nil), artifactContract.RequiredArtifactExts...)
|
||
}
|
||
if len(artifactContract.ExpectedFileCounts) > 0 {
|
||
counts := map[string]int{}
|
||
for ext, count := range artifactContract.ExpectedFileCounts {
|
||
counts[ext] = count
|
||
}
|
||
exportParams["expectedFileCountByExtension"] = counts
|
||
}
|
||
payload := o.openClawArtifactExportRequest(gatewayProvider, exportParams, notify)
|
||
return payload
|
||
}
|
||
|
||
func (o *SessionOrchestrator) openClawArtifactExportRequest(
|
||
gatewayProvider string,
|
||
exportParams map[string]any,
|
||
notify func(map[string]any),
|
||
) map[string]any {
|
||
exportResult := o.openClawGatewayRequestWithRetry(
|
||
gatewayProvider,
|
||
"xworkmate.artifacts.export",
|
||
exportParams,
|
||
30*time.Second,
|
||
notify,
|
||
)
|
||
if exportResult.OK {
|
||
return shared.AsMap(exportResult.Payload)
|
||
}
|
||
message := strings.TrimSpace(shared.StringArg(exportResult.Error, "message", ""))
|
||
if message == "" {
|
||
message = "openclaw artifact export unavailable"
|
||
}
|
||
return map[string]any{
|
||
"artifactWarnings": []any{message},
|
||
}
|
||
}
|
||
|
||
func mergeOpenClawArtifactPayload(result map[string]any, source map[string]any) {
|
||
if result == nil || len(source) == 0 {
|
||
return
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", "")) == "" {
|
||
if remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(source, "remoteWorkingDirectory", "")); remoteWorkingDirectory != "" {
|
||
result["remoteWorkingDirectory"] = remoteWorkingDirectory
|
||
}
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "remoteWorkspaceRefKind", "")) == "" {
|
||
if remoteWorkspaceRefKind := strings.TrimSpace(shared.StringArg(source, "remoteWorkspaceRefKind", "")); remoteWorkspaceRefKind != "" {
|
||
result["remoteWorkspaceRefKind"] = remoteWorkspaceRefKind
|
||
}
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "artifactScope", "")) == "" {
|
||
if artifactScope := strings.TrimSpace(shared.StringArg(source, "artifactScope", "")); artifactScope != "" {
|
||
result["artifactScope"] = artifactScope
|
||
}
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(result, "scopeKind", "")) == "" {
|
||
if scopeKind := strings.TrimSpace(shared.StringArg(source, "scopeKind", "")); scopeKind != "" {
|
||
result["scopeKind"] = scopeKind
|
||
}
|
||
}
|
||
for _, key := range []string{"artifacts", "files", "attachments", "artifactWarnings", "warnings"} {
|
||
merged := appendArtifactList(result[key], source[key])
|
||
if len(merged) > 0 {
|
||
if key == "warnings" {
|
||
result["artifactWarnings"] = appendArtifactList(result["artifactWarnings"], source[key])
|
||
continue
|
||
}
|
||
result[key] = merged
|
||
}
|
||
}
|
||
if value, ok := source["constraintSatisfied"]; ok {
|
||
result["constraintSatisfied"] = parseBool(value)
|
||
}
|
||
if _, ok := source["missingRequiredExtensions"]; ok {
|
||
result["missingRequiredExtensions"] = appendStringList(result["missingRequiredExtensions"], source["missingRequiredExtensions"])
|
||
}
|
||
if value, ok := source["missingRequiredFileCounts"]; ok {
|
||
result["missingRequiredFileCounts"] = value
|
||
}
|
||
}
|
||
|
||
func appendStringList(existing any, incoming any) []any {
|
||
seen := map[string]bool{}
|
||
merged := make([]any, 0)
|
||
add := func(value any) {
|
||
item := strings.TrimSpace(fmt.Sprint(value))
|
||
if item == "" || seen[item] {
|
||
return
|
||
}
|
||
seen[item] = true
|
||
merged = append(merged, item)
|
||
}
|
||
for _, values := range []any{existing, incoming} {
|
||
switch typed := values.(type) {
|
||
case []any:
|
||
for _, item := range typed {
|
||
add(item)
|
||
}
|
||
case []string:
|
||
for _, item := range typed {
|
||
add(item)
|
||
}
|
||
}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func appendArtifactList(existing any, incoming any) []any {
|
||
merged := make([]any, 0)
|
||
switch typed := existing.(type) {
|
||
case []any:
|
||
merged = append(merged, typed...)
|
||
case []map[string]any:
|
||
for _, item := range typed {
|
||
merged = append(merged, item)
|
||
}
|
||
}
|
||
switch typed := incoming.(type) {
|
||
case []any:
|
||
merged = append(merged, typed...)
|
||
case []map[string]any:
|
||
for _, item := range typed {
|
||
merged = append(merged, item)
|
||
}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func applyOpenClawConstraintDeliveryStatus(result map[string]any) {
|
||
if result == nil || !parseBool(result["success"]) {
|
||
return
|
||
}
|
||
if value, ok := result["constraintSatisfied"]; !ok || parseBool(value) {
|
||
return
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(shared.StringArg(result, "status", ""))) {
|
||
case string(TaskStateRunning), string(TaskStateFailed), string(TaskStateCancelled):
|
||
return
|
||
default:
|
||
result["status"] = "partially_delivered"
|
||
result["artifactSyncStatus"] = "partial"
|
||
}
|
||
}
|
||
|
||
func gatewayRPCError(errorPayload map[string]any, fallback string) *shared.RPCError {
|
||
if isOpenClawRetryableGatewayError(errorPayload) {
|
||
return &shared.RPCError{
|
||
Code: -32002,
|
||
Message: "OPENCLAW_GATEWAY_SOCKET_CLOSED: OpenClaw gateway connection closed during task execution",
|
||
Data: map[string]any{
|
||
"code": "OPENCLAW_GATEWAY_SOCKET_CLOSED",
|
||
"originalCode": strings.TrimSpace(shared.StringArg(errorPayload, "code", "")),
|
||
"originalError": strings.TrimSpace(shared.StringArg(errorPayload, "message", "")),
|
||
},
|
||
}
|
||
}
|
||
message := strings.TrimSpace(shared.StringArg(errorPayload, "message", fallback))
|
||
if message == "" {
|
||
message = fallback
|
||
}
|
||
data := map[string]any{}
|
||
if code := strings.TrimSpace(shared.StringArg(errorPayload, "code", "")); code != "" {
|
||
data["code"] = code
|
||
}
|
||
if len(data) == 0 {
|
||
return &shared.RPCError{Code: -32002, Message: message}
|
||
}
|
||
return &shared.RPCError{Code: -32002, Message: message, Data: data}
|
||
}
|
||
|
||
func sessionContinuationUnavailableRPCError(err sessionContinuationUnavailableError) *shared.RPCError {
|
||
return &shared.RPCError{
|
||
Code: -32002,
|
||
Message: "SESSION_CONTINUATION_UNAVAILABLE: provider session state is unavailable",
|
||
Data: map[string]any{
|
||
"code": "SESSION_CONTINUATION_UNAVAILABLE",
|
||
"sessionId": err.sessionID,
|
||
"threadId": err.threadID,
|
||
"providerId": err.providerID,
|
||
"reason": err.reason,
|
||
},
|
||
}
|
||
}
|
||
|
||
func (o *SessionOrchestrator) openClawGatewayRequestWithRetry(
|
||
gatewayProvider string,
|
||
method string,
|
||
params map[string]any,
|
||
timeout time.Duration,
|
||
notify func(map[string]any),
|
||
) gatewayruntime.RequestResult {
|
||
result := o.server.gateway.RequestByMode(
|
||
gatewayProvider,
|
||
method,
|
||
params,
|
||
timeout,
|
||
notify,
|
||
)
|
||
if result.OK || !isOpenClawRetryableGatewayError(result.Error) {
|
||
return result
|
||
}
|
||
if rpcErr := ensureProductionGatewayConnected(o.server, gatewayProvider, notify); rpcErr != nil {
|
||
return result
|
||
}
|
||
return o.server.gateway.RequestByMode(
|
||
gatewayProvider,
|
||
method,
|
||
params,
|
||
timeout,
|
||
notify,
|
||
)
|
||
}
|
||
|
||
func isOpenClawRetryableGatewayError(errorPayload map[string]any) bool {
|
||
code := strings.TrimSpace(strings.ToUpper(shared.StringArg(errorPayload, "code", "")))
|
||
if code == "SOCKET_CLOSED" || code == "SOCKET_FAILURE" || code == "OFFLINE" || code == "INVALID_HANDSHAKE" {
|
||
return true
|
||
}
|
||
message := strings.TrimSpace(strings.ToLower(shared.StringArg(errorPayload, "message", "")))
|
||
return strings.Contains(message, "socket closed") ||
|
||
strings.Contains(message, "invalid handshake") ||
|
||
strings.Contains(message, "first request must be connect")
|
||
}
|
||
|
||
func firstNonEmptyString(values map[string]any, keys ...string) string {
|
||
for _, key := range keys {
|
||
if value := strings.TrimSpace(shared.StringArg(values, key, "")); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func openClawGatewaySessionUpdate(notification map[string]any, sessionID string, threadID string, turnID string) map[string]any {
|
||
params := shared.AsMap(notification["params"])
|
||
event := shared.AsMap(params["event"])
|
||
if strings.TrimSpace(shared.StringArg(event, "event", "")) != "chat.run" {
|
||
return nil
|
||
}
|
||
payload := shared.AsMap(event["payload"])
|
||
text := firstNonEmptyString(payload, "assistantText", "text", "message", "output", "summary")
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
update := map[string]any{
|
||
"sessionId": sessionID,
|
||
"threadId": threadID,
|
||
"turnId": turnID,
|
||
"type": "delta",
|
||
"event": "delta",
|
||
"delta": text,
|
||
"text": text,
|
||
"pending": true,
|
||
"error": false,
|
||
}
|
||
if isTerminalGatewayPayload(payload) {
|
||
update["type"] = "status"
|
||
update["event"] = "completed"
|
||
update["message"] = text
|
||
update["pending"] = false
|
||
if strings.EqualFold(strings.TrimSpace(shared.StringArg(payload, "state", "")), "error") {
|
||
update["error"] = true
|
||
}
|
||
}
|
||
return shared.NotificationEnvelope("session.update", update)
|
||
}
|
||
|
||
func isTerminalGatewayPayload(payload map[string]any) bool {
|
||
if payload == nil {
|
||
return false
|
||
}
|
||
if value, ok := payload["terminal"].(bool); ok && value {
|
||
return true
|
||
}
|
||
switch strings.TrimSpace(strings.ToLower(shared.StringArg(payload, "state", ""))) {
|
||
case "complete", "completed", "done", "final", "ok", "success", "failed", "error", "timeout", "timed_out", "cancelled", "canceled":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func resolvedGatewayProviderID(params map[string]any, routing RoutingResult) string {
|
||
for _, value := range []string{
|
||
routing.GatewayProviderID,
|
||
shared.StringArg(params, "gatewayProvider", ""),
|
||
shared.StringArg(params, "gatewayProviderId", ""),
|
||
} {
|
||
if provider := strings.TrimSpace(value); provider != "" {
|
||
return provider
|
||
}
|
||
}
|
||
routingParams := shared.AsMap(params["routing"])
|
||
for _, key := range []string{
|
||
"gatewayProvider",
|
||
"gatewayProviderId",
|
||
"preferredGatewayProviderId",
|
||
} {
|
||
if provider := strings.TrimSpace(shared.StringArg(routingParams, key, "")); provider != "" {
|
||
return provider
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func withResolvedGatewayProvider(params map[string]any, gatewayProvider string) map[string]any {
|
||
next := make(map[string]any, len(params)+2)
|
||
for key, value := range params {
|
||
next[key] = value
|
||
}
|
||
next["gatewayProvider"] = gatewayProvider
|
||
next["gatewayProviderId"] = gatewayProvider
|
||
return next
|
||
}
|
||
|
||
func (o *SessionOrchestrator) formatUnavailable(res RoutingResult) map[string]any {
|
||
return map[string]any{
|
||
"success": false,
|
||
"status": "unavailable",
|
||
"unavailable": true,
|
||
"unavailableCode": res.UnavailableCode,
|
||
"unavailableMessage": res.UnavailableMsg,
|
||
"resolvedExecutionTarget": res.TargetID,
|
||
"resolvedProviderId": res.ProviderID,
|
||
"resolvedGatewayProviderId": res.GatewayProviderID,
|
||
"resolvedModel": res.Model,
|
||
"resolvedSkills": append([]string(nil), res.Skills...),
|
||
}
|
||
}
|
||
|
||
func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]any, routing RoutingResult, turnID string, params map[string]any) map[string]any {
|
||
if result == nil {
|
||
result = map[string]any{}
|
||
}
|
||
if routing.TargetID == "gateway" && strings.TrimSpace(shared.StringArg(result, "status", "")) == string(TaskStateRunning) {
|
||
result["turnId"] = turnID
|
||
result["success"] = true
|
||
result["resolvedExecutionTarget"] = routing.TargetID
|
||
result["resolvedProviderId"] = routing.ProviderID
|
||
result["resolvedGatewayProviderId"] = routing.GatewayProviderID
|
||
result["resolvedModel"] = routing.Model
|
||
result["resolvedSkills"] = append([]string(nil), routing.Skills...)
|
||
sess.mu.Lock()
|
||
sess.task.State = TaskStateRunning
|
||
sess.task.UpdatedAt = time.Now()
|
||
sess.lastResult = cloneMap(result)
|
||
sess.mu.Unlock()
|
||
return result
|
||
}
|
||
if openClawArtifactResponse(result, routing, params) {
|
||
o.completeOpenClawScopedArtifactExport(result, params, openClawGatewayProviderForArtifacts(result, routing, params), turnID)
|
||
}
|
||
delete(result, openClawArtifactExportAttemptedField)
|
||
|
||
successValue, hasSuccess := result["success"]
|
||
successSource := "explicit"
|
||
success := parseBool(successValue)
|
||
if !hasSuccess {
|
||
successSource = "absent"
|
||
success = true
|
||
}
|
||
|
||
output := strings.TrimSpace(shared.StringArg(result, "output", ""))
|
||
if output == "" {
|
||
output = strings.TrimSpace(shared.StringArg(result, "summary", ""))
|
||
}
|
||
if output == "" && success {
|
||
output = strings.TrimSpace(shared.StringArg(result, "message", ""))
|
||
}
|
||
if routing.TargetID == "gateway" && successSource == "absent" {
|
||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||
if output == "" && len(extractArtifactPayloads(result, remoteWorkingDirectory)) == 0 {
|
||
result["success"] = false
|
||
result["status"] = string(TaskStateFailed)
|
||
result["code"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||
result["error"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||
result["message"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||
} else {
|
||
result["success"] = true
|
||
result["successSource"] = "inferred"
|
||
}
|
||
}
|
||
|
||
sess.mu.Lock()
|
||
if output != "" {
|
||
sess.history = append(sess.history, "ASSISTANT: "+output)
|
||
}
|
||
sess.task.State = TaskStateCompleted
|
||
sess.task.UpdatedAt = time.Now()
|
||
sess.mu.Unlock()
|
||
|
||
result["turnId"] = turnID
|
||
if strings.TrimSpace(shared.StringArg(result, "status", "")) == "" {
|
||
result["status"] = "completed"
|
||
}
|
||
if !hasSuccess {
|
||
if _, ok := result["success"]; !ok {
|
||
result["success"] = true
|
||
}
|
||
}
|
||
if !parseBool(result["success"]) && strings.TrimSpace(shared.StringArg(result, "status", "")) == string(TaskStateCompleted) {
|
||
result["status"] = string(TaskStateFailed)
|
||
}
|
||
result["resolvedExecutionTarget"] = routing.TargetID
|
||
result["resolvedProviderId"] = routing.ProviderID
|
||
result["resolvedGatewayProviderId"] = routing.GatewayProviderID
|
||
result["resolvedModel"] = routing.Model
|
||
result["resolvedSkills"] = append([]string(nil), routing.Skills...)
|
||
if output != "" {
|
||
result["output"] = output
|
||
if _, ok := result["summary"]; !ok {
|
||
result["summary"] = output
|
||
}
|
||
}
|
||
if output == "" && routing.TargetID != "gateway" && !parseBool(result["success"]) {
|
||
result["status"] = "failed"
|
||
} else if output == "" && routing.TargetID != "gateway" {
|
||
result["success"] = false
|
||
result["status"] = "failed"
|
||
result["error"] = "provider returned no displayable output"
|
||
result["message"] = "provider returned no displayable output"
|
||
}
|
||
if !parseBool(result["success"]) {
|
||
sess.mu.Lock()
|
||
sess.task.State = TaskStateFailed
|
||
sess.task.UpdatedAt = time.Now()
|
||
sess.mu.Unlock()
|
||
}
|
||
applyOpenClawConstraintDeliveryStatus(result)
|
||
|
||
artifactRecord := buildArtifactRecord(sess, result, output)
|
||
if artifactRecord.RemoteWorkingDirectory != "" {
|
||
result["remoteWorkingDirectory"] = artifactRecord.RemoteWorkingDirectory
|
||
}
|
||
if artifactRecord.RemoteWorkspaceRefKind != "" {
|
||
result["remoteWorkspaceRefKind"] = artifactRecord.RemoteWorkspaceRefKind
|
||
}
|
||
if artifactRecord.ResultSummary != "" && strings.TrimSpace(shared.StringArg(result, "resultSummary", "")) == "" {
|
||
result["resultSummary"] = artifactRecord.ResultSummary
|
||
}
|
||
if len(artifactRecord.Artifacts) > 0 {
|
||
result["artifacts"] = artifactRecord.Artifacts
|
||
}
|
||
if openClawArtifactResponse(result, routing, params) {
|
||
stripOpenClawArtifactInlineContent(result)
|
||
}
|
||
|
||
workingDirectory := shared.StringArg(params, "workingDirectory", "")
|
||
routingParams := shared.AsMap(params["routing"])
|
||
routingMode := strings.TrimSpace(shared.StringArg(routingParams, "routingMode", ""))
|
||
if workingDirectory != "" && routingMode == "auto" {
|
||
_ = o.server.memoryService.RecordSuccess(workingDirectory, memory.SuccessEntry{
|
||
ResolvedExecutionTarget: routing.TargetID,
|
||
ResolvedProviderID: routing.ProviderID,
|
||
ResolvedModel: routing.Model,
|
||
ResolvedSkills: routing.Skills,
|
||
Summary: output,
|
||
})
|
||
}
|
||
|
||
sess.mu.Lock()
|
||
sess.lastResult = cloneMap(result)
|
||
sess.mu.Unlock()
|
||
|
||
return result
|
||
}
|
||
|
||
func (o *SessionOrchestrator) completeOpenClawScopedArtifactExport(
|
||
result map[string]any,
|
||
params map[string]any,
|
||
gatewayProvider string,
|
||
turnID string,
|
||
) {
|
||
if result == nil || o.server == nil || o.server.gateway == nil {
|
||
return
|
||
}
|
||
if !parseBool(result["success"]) {
|
||
return
|
||
}
|
||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||
if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 {
|
||
return
|
||
}
|
||
if parseBool(result[openClawArtifactExportAttemptedField]) {
|
||
return
|
||
}
|
||
preparedArtifact := openClawPreparedArtifactScopeFromPayload(result)
|
||
if preparedArtifact == nil {
|
||
return
|
||
}
|
||
sessionKey := o.openClawSessionKey(params, turnID)
|
||
runID := strings.TrimSpace(shared.StringArg(result, "runId", turnID))
|
||
chatParams := map[string]any{"sessionKey": sessionKey}
|
||
artifactContract := openClawArtifactContractForParams(params, chatParams)
|
||
mergeOpenClawArtifactPayload(result, o.openClawArtifactExport(
|
||
gatewayProvider,
|
||
chatParams,
|
||
artifactContract,
|
||
runID,
|
||
0,
|
||
preparedArtifact,
|
||
nil,
|
||
))
|
||
o.server.decorateOpenClawArtifactDownloadURLs(result, sessionKey, runID)
|
||
stripOpenClawArtifactInlineContent(result)
|
||
}
|
||
|
||
func openClawGatewayProviderForArtifacts(result map[string]any, routing RoutingResult, params map[string]any) string {
|
||
for _, provider := range []string{
|
||
routing.GatewayProviderID,
|
||
shared.StringArg(result, "resolvedGatewayProviderId", ""),
|
||
shared.StringArg(result, "gatewayProviderId", ""),
|
||
shared.StringArg(result, "gatewayProvider", ""),
|
||
shared.StringArg(params, "gatewayProviderId", ""),
|
||
shared.StringArg(params, "gatewayProvider", ""),
|
||
} {
|
||
if isOpenClawMode(provider) {
|
||
return strings.TrimSpace(provider)
|
||
}
|
||
}
|
||
routingParams := shared.AsMap(params["routing"])
|
||
for _, key := range []string{"preferredGatewayProviderId", "gatewayProviderId", "gatewayProvider"} {
|
||
if provider := strings.TrimSpace(shared.StringArg(routingParams, key, "")); isOpenClawMode(provider) {
|
||
return provider
|
||
}
|
||
}
|
||
return "openclaw"
|
||
}
|
||
|
||
func openClawArtifactResponse(result map[string]any, routing RoutingResult, params map[string]any) bool {
|
||
for _, provider := range []string{
|
||
routing.GatewayProviderID,
|
||
shared.StringArg(result, "resolvedGatewayProviderId", ""),
|
||
shared.StringArg(result, "gatewayProviderId", ""),
|
||
shared.StringArg(result, "gatewayProvider", ""),
|
||
shared.StringArg(params, "gatewayProviderId", ""),
|
||
shared.StringArg(params, "gatewayProvider", ""),
|
||
} {
|
||
if isOpenClawMode(provider) {
|
||
return true
|
||
}
|
||
}
|
||
routingParams := shared.AsMap(params["routing"])
|
||
for _, key := range []string{"preferredGatewayProviderId", "gatewayProviderId", "gatewayProvider"} {
|
||
if isOpenClawMode(shared.StringArg(routingParams, key, "")) {
|
||
return true
|
||
}
|
||
}
|
||
if strings.HasPrefix(strings.TrimSpace(shared.StringArg(result, "artifactScope", "")), "tasks/") {
|
||
return true
|
||
}
|
||
for _, key := range []string{"artifacts", "files", "attachments"} {
|
||
if openClawArtifactListHasScopedArtifact(result[key]) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func openClawArtifactListHasScopedArtifact(raw any) bool {
|
||
switch values := raw.(type) {
|
||
case []map[string]any:
|
||
for _, artifact := range values {
|
||
if openClawArtifactHasBridgeDownloadRef(artifact) {
|
||
return true
|
||
}
|
||
}
|
||
case []any:
|
||
for _, item := range values {
|
||
artifact := shared.AsMap(item)
|
||
if openClawArtifactHasBridgeDownloadRef(artifact) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func openClawArtifactHasBridgeDownloadRef(artifact map[string]any) bool {
|
||
if strings.HasPrefix(strings.TrimSpace(shared.StringArg(artifact, "artifactScope", "")), "tasks/") {
|
||
return true
|
||
}
|
||
downloadURL := strings.TrimSpace(shared.StringArg(artifact, "downloadUrl", ""))
|
||
if downloadURL == "" {
|
||
downloadURL = strings.TrimSpace(shared.StringArg(artifact, "downloadURL", ""))
|
||
}
|
||
return strings.Contains(downloadURL, "/artifacts/openclaw/download")
|
||
}
|
||
|
||
func taskKindFromParams(params map[string]any, routing RoutingResult) TaskKind {
|
||
if routing.TargetID == "gateway" {
|
||
return TaskKindGateway
|
||
}
|
||
return TaskKindSingleAgent
|
||
}
|
||
|
||
func buildArtifactRecord(sess *session, result map[string]any, output string) ArtifactRecord {
|
||
record := ArtifactRecord{
|
||
SessionID: sess.sessionID,
|
||
ThreadID: sess.threadID,
|
||
ResultSummary: strings.TrimSpace(output),
|
||
UpdatedAt: time.Now(),
|
||
}
|
||
if record.ResultSummary == "" {
|
||
record.ResultSummary = strings.TrimSpace(shared.StringArg(result, "resultSummary", ""))
|
||
}
|
||
if record.ResultSummary == "" {
|
||
record.ResultSummary = strings.TrimSpace(shared.StringArg(result, "summary", ""))
|
||
}
|
||
record.RemoteWorkingDirectory = strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||
if record.RemoteWorkingDirectory == "" {
|
||
record.RemoteWorkingDirectory = strings.TrimSpace(sess.control.RemoteWorkingDirHint)
|
||
}
|
||
record.RemoteWorkspaceRefKind = strings.TrimSpace(shared.StringArg(result, "remoteWorkspaceRefKind", ""))
|
||
if record.RemoteWorkspaceRefKind == "" && record.RemoteWorkingDirectory != "" {
|
||
record.RemoteWorkspaceRefKind = "remotePath"
|
||
}
|
||
record.Artifacts = extractArtifactPayloads(result, record.RemoteWorkingDirectory)
|
||
sess.mu.Lock()
|
||
sess.artifacts = record
|
||
sess.control.UpdatedAt = record.UpdatedAt
|
||
sess.mu.Unlock()
|
||
return record
|
||
}
|
||
|
||
func extractArtifactPayloads(result map[string]any, remoteWorkingDirectory string) []map[string]any {
|
||
artifacts := make([]map[string]any, 0)
|
||
for _, key := range []string{"artifacts", "files", "attachments"} {
|
||
rawArtifacts := result[key]
|
||
items, ok := rawArtifacts.([]any)
|
||
if !ok {
|
||
if typed, ok := rawArtifacts.([]map[string]any); ok {
|
||
for _, item := range typed {
|
||
if artifact := normalizeArtifactPayload(item, remoteWorkingDirectory); len(artifact) > 0 {
|
||
artifacts = append(artifacts, artifact)
|
||
}
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
for _, item := range items {
|
||
if mapped := shared.AsMap(item); len(mapped) > 0 {
|
||
if artifact := normalizeArtifactPayload(mapped, remoteWorkingDirectory); len(artifact) > 0 {
|
||
artifacts = append(artifacts, artifact)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return artifacts
|
||
}
|
||
|
||
func normalizeArtifactPayload(item map[string]any, remoteWorkingDirectory string) map[string]any {
|
||
artifact := make(map[string]any, len(item)+4)
|
||
for key, value := range item {
|
||
artifact[key] = value
|
||
}
|
||
relativePath := strings.TrimSpace(shared.StringArg(artifact, "relativePath", ""))
|
||
if relativePath == "" {
|
||
relativePath = strings.TrimSpace(shared.StringArg(artifact, "path", ""))
|
||
}
|
||
if relativePath == "" {
|
||
relativePath = strings.TrimSpace(shared.StringArg(artifact, "name", ""))
|
||
}
|
||
downloadURL := artifactDownloadURL(artifact)
|
||
if relativePath == "" && downloadURL != "" {
|
||
relativePath = artifactRelativePathFromDownloadURL(downloadURL)
|
||
}
|
||
relativePath = safeArtifactRelativePath(remoteWorkingDirectory, relativePath)
|
||
if relativePath == "" {
|
||
return nil
|
||
}
|
||
artifact["relativePath"] = relativePath
|
||
if downloadURL != "" {
|
||
artifact["downloadUrl"] = downloadURL
|
||
delete(artifact, "downloadURL")
|
||
delete(artifact, "download_url")
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(artifact, "label", "")) == "" {
|
||
artifact["label"] = filepath.Base(relativePath)
|
||
}
|
||
if strings.TrimSpace(shared.StringArg(artifact, "contentType", "")) == "" {
|
||
artifact["contentType"] = artifactContentType(relativePath)
|
||
}
|
||
return artifact
|
||
}
|
||
|
||
func artifactDownloadURL(artifact map[string]any) string {
|
||
for _, key := range []string{"downloadUrl", "downloadURL", "download_url"} {
|
||
if value := strings.TrimSpace(shared.StringArg(artifact, key, "")); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func artifactRelativePathFromDownloadURL(raw string) string {
|
||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
base := strings.TrimSpace(filepath.Base(parsed.Path))
|
||
if base == "" || base == "." || base == "/" {
|
||
sum := sha256.Sum256([]byte(raw))
|
||
base = fmt.Sprintf("artifact-%x.bin", sum[:6])
|
||
}
|
||
return base
|
||
}
|
||
|
||
func safeArtifactRelativePath(root string, rawPath string) string {
|
||
path := strings.TrimSpace(rawPath)
|
||
if path == "" || strings.Contains(path, "\x00") {
|
||
return ""
|
||
}
|
||
path = filepath.ToSlash(path)
|
||
if strings.TrimSpace(root) != "" && filepath.IsAbs(path) {
|
||
rel, err := filepath.Rel(root, path)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
path = filepath.ToSlash(rel)
|
||
}
|
||
path = filepath.Clean(filepath.FromSlash(path))
|
||
if path == "." || filepath.IsAbs(path) || strings.HasPrefix(path, ".."+string(os.PathSeparator)) || path == ".." {
|
||
return ""
|
||
}
|
||
return filepath.ToSlash(path)
|
||
}
|
||
|
||
func artifactContentType(relativePath string) string {
|
||
switch strings.ToLower(filepath.Ext(relativePath)) {
|
||
case ".pdf":
|
||
return "application/pdf"
|
||
case ".pptx":
|
||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||
case ".docx":
|
||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||
case ".txt", ".md":
|
||
return "text/plain"
|
||
default:
|
||
return "application/octet-stream"
|
||
}
|
||
}
|
||
|
||
func (s *Server) getOrCreateSession(sessionID, threadID string) *session {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
sess, ok := s.sessions[sessionID]
|
||
if !ok {
|
||
sess = &session{
|
||
sessionID: sessionID,
|
||
threadID: threadID,
|
||
}
|
||
s.sessions[sessionID] = sess
|
||
}
|
||
return sess
|
||
}
|
||
|
||
func (s *Server) emitSessionUpdate(notify func(map[string]any), turnID string, update map[string]any) {
|
||
if notify == nil || update == nil {
|
||
return
|
||
}
|
||
update["turnId"] = turnID
|
||
notify(shared.NotificationEnvelope("session.update", update))
|
||
}
|