mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
Some OpenClaw JSON outputs contain durationMs but lack payloads field. The original condition rejected these results, causing the agent to return "openclaw returned no parseable output" instead of the actual execution result. Fix by accepting results that have either payloads OR durationMs > 0. Fixes #830 Co-authored-by: leaderlemon <leaderlemon@users.noreply.github.com>
248 lines
6.9 KiB
Go
248 lines
6.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// openclawBackend implements Backend by spawning `openclaw agent --message <prompt>
|
|
// --output-format stream-json --yes` and reading streaming NDJSON events from
|
|
// stdout — similar to the opencode backend.
|
|
type openclawBackend struct {
|
|
cfg Config
|
|
}
|
|
|
|
func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execPath := b.cfg.ExecutablePath
|
|
if execPath == "" {
|
|
execPath = "openclaw"
|
|
}
|
|
if _, err := exec.LookPath(execPath); err != nil {
|
|
return nil, fmt.Errorf("openclaw executable not found at %q: %w", execPath, err)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
if timeout == 0 {
|
|
timeout = 20 * time.Minute
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
|
|
sessionID := opts.ResumeSessionID
|
|
if sessionID == "" {
|
|
sessionID = fmt.Sprintf("multica-%d", time.Now().UnixNano())
|
|
}
|
|
args := []string{"agent", "--local", "--json", "--session-id", sessionID}
|
|
if opts.Timeout > 0 {
|
|
args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())))
|
|
}
|
|
args = append(args, "--message", prompt)
|
|
|
|
cmd := exec.CommandContext(runCtx, execPath, args...)
|
|
if opts.Cwd != "" {
|
|
cmd.Dir = opts.Cwd
|
|
}
|
|
cmd.Env = buildEnv(b.cfg.Env)
|
|
|
|
// openclaw writes its --json output to stderr, not stdout.
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("openclaw stderr pipe: %w", err)
|
|
}
|
|
cmd.Stdout = newLogWriter(b.cfg.Logger, "[openclaw:stdout] ")
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("start openclaw: %w", err)
|
|
}
|
|
|
|
b.cfg.Logger.Info("openclaw started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
|
|
|
msgCh := make(chan Message, 256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
|
|
startTime := time.Now()
|
|
scanResult := b.processOutput(stderr, msgCh)
|
|
|
|
// Wait for process exit.
|
|
exitErr := cmd.Wait()
|
|
duration := time.Since(startTime)
|
|
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
scanResult.status = "timeout"
|
|
scanResult.errMsg = fmt.Sprintf("openclaw timed out after %s", timeout)
|
|
} else if runCtx.Err() == context.Canceled {
|
|
scanResult.status = "aborted"
|
|
scanResult.errMsg = "execution cancelled"
|
|
} else if exitErr != nil && scanResult.status == "completed" {
|
|
scanResult.status = "failed"
|
|
scanResult.errMsg = fmt.Sprintf("openclaw exited with error: %v", exitErr)
|
|
}
|
|
|
|
b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
// Build usage map. OpenClaw doesn't report model per-step, so we
|
|
// attribute all usage to the configured model (or "unknown").
|
|
var usage map[string]TokenUsage
|
|
u := scanResult.usage
|
|
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
|
|
model := opts.Model
|
|
if model == "" {
|
|
model = "unknown"
|
|
}
|
|
usage = map[string]TokenUsage{model: u}
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: scanResult.status,
|
|
Output: scanResult.output,
|
|
Error: scanResult.errMsg,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: scanResult.sessionID,
|
|
Usage: usage,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
// ── Event handlers ──
|
|
|
|
// openclawEventResult holds accumulated state from processing the event stream.
|
|
type openclawEventResult struct {
|
|
status string
|
|
errMsg string
|
|
output string
|
|
sessionID string
|
|
usage TokenUsage
|
|
}
|
|
|
|
// processOutput reads the JSON output from openclaw --json stderr and returns
|
|
// the parsed result. OpenClaw writes its JSON result to stderr, which may also
|
|
// contain non-JSON log lines. We scan line-by-line so a final result line can
|
|
// be recognized without waiting for the entire stderr stream to be buffered.
|
|
func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclawEventResult {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
|
|
|
var rawLines []string
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if result, ok := tryParseOpenclawResult(line); ok {
|
|
return b.buildOpenclawEventResult(result, ch)
|
|
}
|
|
b.cfg.Logger.Debug("[openclaw:stderr] " + line)
|
|
rawLines = append(rawLines, line)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stderr: %v", err)}
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(strings.Join(rawLines, "\n"))
|
|
if trimmed != "" {
|
|
return openclawEventResult{status: "completed", output: trimmed}
|
|
}
|
|
return openclawEventResult{status: "failed", errMsg: "openclaw returned no parseable output"}
|
|
}
|
|
|
|
func tryParseOpenclawResult(raw string) (openclawResult, bool) {
|
|
// Try each '{' position until we find valid openclawResult JSON.
|
|
// Earlier '{' chars may appear in log/error lines (e.g. raw_params={...}).
|
|
var result openclawResult
|
|
for i := 0; i < len(raw); i++ {
|
|
if raw[i] != '{' {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal([]byte(raw[i:]), &result); err == nil && (result.Payloads != nil || result.Meta.DurationMs > 0) {
|
|
return result, true
|
|
}
|
|
}
|
|
return openclawResult{}, false
|
|
}
|
|
|
|
func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch chan<- Message) openclawEventResult {
|
|
var output strings.Builder
|
|
for _, p := range result.Payloads {
|
|
if p.Text != "" {
|
|
if output.Len() > 0 {
|
|
output.WriteString("\n")
|
|
}
|
|
output.WriteString(p.Text)
|
|
}
|
|
}
|
|
|
|
var sessionID string
|
|
var usage TokenUsage
|
|
if result.Meta.AgentMeta != nil {
|
|
if sid, ok := result.Meta.AgentMeta["sessionId"].(string); ok {
|
|
sessionID = sid
|
|
}
|
|
if u, ok := result.Meta.AgentMeta["usage"].(map[string]any); ok {
|
|
usage.InputTokens = openclawInt64(u, "input")
|
|
usage.OutputTokens = openclawInt64(u, "output")
|
|
usage.CacheReadTokens = openclawInt64(u, "cacheRead")
|
|
usage.CacheWriteTokens = openclawInt64(u, "cacheWrite")
|
|
}
|
|
}
|
|
|
|
if output.Len() > 0 {
|
|
trySend(ch, Message{Type: MessageText, Content: output.String()})
|
|
}
|
|
|
|
return openclawEventResult{
|
|
status: "completed",
|
|
output: output.String(),
|
|
sessionID: sessionID,
|
|
usage: usage,
|
|
}
|
|
}
|
|
|
|
// openclawInt64 safely extracts an int64 from a JSON-decoded map value (which
|
|
// may be float64 due to Go's JSON number handling).
|
|
func openclawInt64(data map[string]any, key string) int64 {
|
|
v, ok := data[key]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int64(n)
|
|
case int64:
|
|
return n
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// ── JSON types for `openclaw agent --json` output ──
|
|
|
|
// openclawResult represents the JSON output from `openclaw agent --json`.
|
|
type openclawResult struct {
|
|
Payloads []openclawPayload `json:"payloads"`
|
|
Meta openclawMeta `json:"meta"`
|
|
}
|
|
|
|
type openclawPayload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type openclawMeta struct {
|
|
DurationMs int64 `json:"durationMs"`
|
|
AgentMeta map[string]any `json:"agentMeta"`
|
|
}
|