- Delete internal/acp/multi_agent.go (610 lines) — Path B orchestration - Remove multiAgent dispatch from orchestrator, jobs, http_handler - Set multiAgent/multi_agent capabilities to false in catalog - Remove TaskKindMultiAgent from types - Remove redundant ErrorResponse() wrapper in shared/rpc.go - Add anti-fallback coding standards to AGENTS.md
355 lines
10 KiB
Go
355 lines
10 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"xworkmate-bridge/internal/shared"
|
|
)
|
|
|
|
const (
|
|
defaultJobTimeout = 10 * time.Minute
|
|
defaultWebhookMaxTries = 3
|
|
defaultWebhookRetryWait = 2 * time.Second
|
|
)
|
|
|
|
type jobManager struct {
|
|
server *Server
|
|
mu sync.Mutex
|
|
jobs map[string]*bridgeJob
|
|
}
|
|
|
|
type bridgeJob struct {
|
|
JobID string
|
|
SessionID string
|
|
ThreadID string
|
|
ProviderID string
|
|
Prompt string
|
|
WorkingDir string
|
|
Status string
|
|
Result map[string]any
|
|
Error string
|
|
CreatedAt time.Time
|
|
StartedAt time.Time
|
|
CompletedAt time.Time
|
|
Timeout time.Duration
|
|
CallbackURL string
|
|
WebhookTries int
|
|
WebhookSent bool
|
|
Target string
|
|
Channel string
|
|
AccountID string
|
|
Params map[string]any
|
|
}
|
|
|
|
func newJobManager(server *Server) *jobManager {
|
|
return &jobManager{server: server, jobs: make(map[string]*bridgeJob)}
|
|
}
|
|
|
|
func (s *Server) handleJobMethod(ctx context.Context, method string, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
|
|
if s.jobs == nil {
|
|
s.jobs = newJobManager(s)
|
|
}
|
|
switch method {
|
|
case "xworkmate.jobs.submit":
|
|
return s.jobs.submit(ctx, params, notify), nil
|
|
case "xworkmate.jobs.get":
|
|
return s.jobs.get(params), nil
|
|
case "xworkmate.jobs.list":
|
|
return s.jobs.list(), nil
|
|
case "xworkmate.jobs.stats":
|
|
return s.jobs.stats(), nil
|
|
default:
|
|
return nil, &shared.RPCError{Code: -32601, Message: "unknown jobs method: " + method}
|
|
}
|
|
}
|
|
|
|
func (m *jobManager) submit(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {
|
|
m.failStuck()
|
|
jobID := fmt.Sprintf("job-%d", time.Now().UnixNano())
|
|
providerID := strings.TrimSpace(firstNonEmptyString(params, "providerId", "agent", "provider"))
|
|
routing := shared.AsMap(params["routing"])
|
|
if providerID == "" {
|
|
providerID = strings.TrimSpace(shared.StringArg(routing, "explicitProviderId", ""))
|
|
}
|
|
timeout := time.Duration(parsePositiveInt(params["timeoutMs"])) * time.Millisecond
|
|
if timeout <= 0 {
|
|
timeout = defaultJobTimeout
|
|
}
|
|
job := &bridgeJob{
|
|
JobID: jobID,
|
|
SessionID: strings.TrimSpace(shared.StringArg(params, "sessionId", jobID)),
|
|
ThreadID: strings.TrimSpace(shared.StringArg(params, "threadId", jobID)),
|
|
ProviderID: providerID,
|
|
Prompt: strings.TrimSpace(shared.StringArg(params, "taskPrompt", shared.StringArg(params, "prompt", ""))),
|
|
WorkingDir: strings.TrimSpace(shared.StringArg(params, "workingDirectory", shared.StringArg(params, "cwd", ""))),
|
|
Status: "pending",
|
|
CreatedAt: time.Now(),
|
|
Timeout: timeout,
|
|
CallbackURL: strings.TrimSpace(shared.StringArg(params, "callbackUrl", shared.StringArg(params, "webhookUrl", ""))),
|
|
Target: strings.TrimSpace(shared.StringArg(params, "target", shared.StringArg(params, "discordTarget", ""))),
|
|
Channel: strings.TrimSpace(shared.StringArg(params, "channel", "discord")),
|
|
AccountID: strings.TrimSpace(shared.StringArg(params, "accountId", "")),
|
|
Params: cloneJobParams(params),
|
|
}
|
|
m.mu.Lock()
|
|
m.jobs[job.JobID] = job
|
|
m.mu.Unlock()
|
|
go m.run(ctx, job, notify)
|
|
return map[string]any{"jobId": job.JobID, "status": job.Status, "sessionId": job.SessionID, "providerId": job.ProviderID}
|
|
}
|
|
|
|
func (m *jobManager) run(parent context.Context, job *bridgeJob, notify func(map[string]any)) {
|
|
m.setRunning(job)
|
|
ctx, cancel := context.WithTimeout(parent, job.Timeout)
|
|
defer cancel()
|
|
params := cloneJobParams(job.Params)
|
|
params["sessionId"] = job.SessionID
|
|
params["threadId"] = job.ThreadID
|
|
params["taskPrompt"] = job.Prompt
|
|
params["workingDirectory"] = job.WorkingDir
|
|
if job.ProviderID != "" {
|
|
params["routing"] = map[string]any{
|
|
"routingMode": "explicit",
|
|
"explicitExecutionTarget": "singleAgent",
|
|
"explicitProviderId": job.ProviderID,
|
|
}
|
|
}
|
|
result, rpcErr := m.server.orchestrator.Process(ctx, "session.start", params, notify)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
job.CompletedAt = time.Now()
|
|
if rpcErr != nil {
|
|
job.Status = "failed"
|
|
job.Error = rpcErr.Message
|
|
} else {
|
|
job.Result = result
|
|
if parseBool(result["success"]) {
|
|
job.Status = "completed"
|
|
} else {
|
|
job.Status = "failed"
|
|
job.Error = firstNonEmptyString(result, "error", "message", "unavailableMessage")
|
|
}
|
|
}
|
|
go m.sendCallbacks(job)
|
|
}
|
|
|
|
func (m *jobManager) setRunning(job *bridgeJob) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
job.Status = "running"
|
|
job.StartedAt = time.Now()
|
|
}
|
|
|
|
func (m *jobManager) get(params map[string]any) map[string]any {
|
|
m.failStuck()
|
|
jobID := strings.TrimSpace(shared.StringArg(params, "jobId", ""))
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if job := m.jobs[jobID]; job != nil {
|
|
return job.mapPayload()
|
|
}
|
|
return map[string]any{"status": "not_found", "jobId": jobID}
|
|
}
|
|
|
|
func (m *jobManager) list() map[string]any {
|
|
m.failStuck()
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
jobs := make([]map[string]any, 0, len(m.jobs))
|
|
counts := map[string]int{"pending": 0, "running": 0, "completed": 0, "failed": 0}
|
|
for _, job := range m.jobs {
|
|
jobs = append(jobs, job.mapPayload())
|
|
counts[job.Status]++
|
|
}
|
|
summary := make(map[string]any, len(counts))
|
|
for key, value := range counts {
|
|
summary[key] = value
|
|
}
|
|
return map[string]any{"jobs": jobs, "summary": summary}
|
|
}
|
|
|
|
func (m *jobManager) stats() map[string]any {
|
|
summary := m.list()["summary"]
|
|
return map[string]any{"summary": summary}
|
|
}
|
|
|
|
func (m *jobManager) failStuck() {
|
|
now := time.Now()
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, job := range m.jobs {
|
|
if job.Status != "running" && job.Status != "pending" {
|
|
continue
|
|
}
|
|
timeout := job.Timeout
|
|
if timeout <= 0 {
|
|
timeout = defaultJobTimeout
|
|
}
|
|
if now.Sub(job.CreatedAt) > timeout {
|
|
job.Status = "failed"
|
|
job.Error = "job exceeded timeout"
|
|
job.CompletedAt = now
|
|
go m.sendCallbacks(job)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *jobManager) sendCallbacks(job *bridgeJob) {
|
|
if job == nil {
|
|
return
|
|
}
|
|
if job.CallbackURL != "" {
|
|
m.sendWebhook(job)
|
|
}
|
|
if job.Target != "" {
|
|
_, _ = m.server.invokeOpenClawTool(context.Background(), map[string]any{
|
|
"tool": "message",
|
|
"action": "send",
|
|
"channel": job.Channel,
|
|
"accountId": job.AccountID,
|
|
"args": map[string]any{
|
|
"channel": job.Channel,
|
|
"target": job.Target,
|
|
"message": job.markdownCard(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
func (m *jobManager) sendWebhook(job *bridgeJob) {
|
|
payload, _ := json.Marshal(job.mapPayload())
|
|
for attempt := 1; attempt <= defaultWebhookMaxTries; attempt++ {
|
|
req, err := http.NewRequest(http.MethodPost, job.CallbackURL, bytes.NewReader(payload))
|
|
if err != nil {
|
|
break
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
job.WebhookTries = attempt
|
|
if err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
job.WebhookSent = true
|
|
if resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
return
|
|
}
|
|
if resp != nil && resp.Body != nil {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
|
|
_ = resp.Body.Close()
|
|
}
|
|
time.Sleep(defaultWebhookRetryWait)
|
|
}
|
|
}
|
|
|
|
func (j *bridgeJob) mapPayload() map[string]any {
|
|
payload := map[string]any{
|
|
"jobId": j.JobID,
|
|
"status": j.Status,
|
|
"sessionId": j.SessionID,
|
|
"threadId": j.ThreadID,
|
|
"providerId": j.ProviderID,
|
|
"createdAt": j.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
"webhookSent": j.WebhookSent,
|
|
"webhookTries": j.WebhookTries,
|
|
}
|
|
if !j.StartedAt.IsZero() {
|
|
payload["startedAt"] = j.StartedAt.UTC().Format(time.RFC3339Nano)
|
|
payload["elapsedMs"] = time.Since(j.StartedAt).Milliseconds()
|
|
}
|
|
if !j.CompletedAt.IsZero() {
|
|
payload["completedAt"] = j.CompletedAt.UTC().Format(time.RFC3339Nano)
|
|
payload["durationMs"] = j.CompletedAt.Sub(j.CreatedAt).Milliseconds()
|
|
}
|
|
if j.Result != nil {
|
|
payload["result"] = j.Result
|
|
}
|
|
if j.Error != "" {
|
|
payload["error"] = j.Error
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func (j *bridgeJob) markdownCard() string {
|
|
title := fmt.Sprintf("### XWorkmate job %s: %s", j.JobID, j.Status)
|
|
if j.Result != nil {
|
|
if summary := firstNonEmptyString(j.Result, "summary", "output", "message"); summary != "" {
|
|
return title + "\n\n" + summary
|
|
}
|
|
}
|
|
if j.Error != "" {
|
|
return title + "\n\n" + j.Error
|
|
}
|
|
return title
|
|
}
|
|
|
|
func (s *Server) invokeOpenClawTool(ctx context.Context, params map[string]any) (map[string]any, *shared.RPCError) {
|
|
payload := map[string]any{
|
|
"tool": strings.TrimSpace(shared.StringArg(params, "tool", "")),
|
|
"action": strings.TrimSpace(shared.StringArg(params, "action", "")),
|
|
"args": shared.AsMap(params["args"]),
|
|
}
|
|
if payload["tool"] == "" {
|
|
return nil, &shared.RPCError{Code: -32602, Message: "TOOL_REQUIRED"}
|
|
}
|
|
toolURL := strings.TrimSpace(os.Getenv("OPENCLAW_TOOLS_INVOKE_URL"))
|
|
if toolURL != "" {
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, toolURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, &shared.RPCError{Code: -32002, Message: err.Error()}
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token := strings.TrimSpace(os.Getenv("OPENCLAW_TOOLS_TOKEN")); token != "" {
|
|
req.Header.Set("Authorization", bearerHeader(token))
|
|
}
|
|
response, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, &shared.RPCError{Code: -32002, Message: err.Error()}
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
var decoded map[string]any
|
|
if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
|
|
return nil, &shared.RPCError{Code: -32002, Message: err.Error()}
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return decoded, &shared.RPCError{Code: -32002, Message: "tools invoke failed"}
|
|
}
|
|
return decoded, nil
|
|
}
|
|
if s.gateway == nil {
|
|
return nil, &shared.RPCError{Code: -32001, Message: "GATEWAY_NOT_INITIALIZED"}
|
|
}
|
|
if rpcErr := ensureProductionGatewayConnected(s, "openclaw", nil); rpcErr != nil {
|
|
return nil, rpcErr
|
|
}
|
|
result := s.gateway.RequestByMode("openclaw", "tools.invoke", payload, 30*time.Second, nil)
|
|
if !result.OK {
|
|
return nil, gatewayRPCError(result.Error, "tools invoke failed")
|
|
}
|
|
return shared.AsMap(result.Payload), nil
|
|
}
|
|
|
|
func bearerHeader(token string) string {
|
|
if strings.HasPrefix(strings.ToLower(token), "bearer ") {
|
|
return token
|
|
}
|
|
return "Bearer " + token
|
|
}
|
|
|
|
func cloneJobParams(params map[string]any) map[string]any {
|
|
next := make(map[string]any, len(params))
|
|
for key, value := range params {
|
|
next[key] = value
|
|
}
|
|
return next
|
|
}
|