mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
fix(agent/openclaw): read --json from stdout, not stderr
Multica's openclaw runtime adapter has been reading agent output from
stderr since the early openclaw integration days. Current openclaw
(2026.5.5, c37871e) writes its --json blob exclusively to stdout:
$ openclaw agent --local --json --agent main --message 'say hi' >stdout 2>stderr
STDOUT bytes: 27401
STDERR bytes: 0
Result: every successful turn was followed by a daemon-generated system
comment 'openclaw returned no parseable output', visible to users,
looked like the agent broke when it didn't. Reproduced live on WOR-2,
turn at 2026-05-05 16:35 UTC; daemon log confirmed the full result JSON
arrived on the [openclaw:stdout] debug channel and was discarded while
the empty stderr pipe hit the no-events fallback.
Changes
- server/pkg/agent/openclaw.go: swap pipes, StdoutPipe() for the JSON
stream, cmd.Stderr = newLogWriter(...) for log overflow. Cleanup
goroutine now closes stdout on cancel. Comments and the read-error
errMsg updated to reflect the new pipe.
- server/pkg/agent/openclaw_test.go: TestOpenclawProcessOutputReadError
asserts on 'read stdout' (was 'read stderr'), string-only fix,
no behavior change. New TestOpenclawProcessOutputStdoutFixture feeds
a recorded openclaw 2026.5.5 --json blob through processOutput and
asserts result + messages parse cleanly.
- server/pkg/agent/testdata/openclaw-2026.5.5-stdout.json: 27401-byte
fixture captured fresh from the openclaw CLI for the regression test.
Side effects (net positive)
- Log lines openclaw writes to stderr (security warnings, tool errors)
now show up under [openclaw:stderr] instead of being silently consumed
by the JSON parser.
- Daemon's success_pattern heuristic (empty-output -> 'blocked')
becomes meaningful again because result.Output actually populates.
Closes WOR-10.
This commit is contained in:
committed by
Joey Frasier (Boothe)
parent
daf0e935f6
commit
c87d7676f6
@@ -60,13 +60,16 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
||||
}
|
||||
cmd.Env = buildEnv(b.cfg.Env)
|
||||
|
||||
// openclaw writes its --json output to stderr, not stdout.
|
||||
stderr, err := cmd.StderrPipe()
|
||||
// openclaw writes its --json output to stdout. Stderr carries log
|
||||
// overflow (security warnings, tool errors, etc.) — capture it via a
|
||||
// log writer so it surfaces in daemon logs without being fed into the
|
||||
// JSON parser.
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("openclaw stderr pipe: %w", err)
|
||||
return nil, fmt.Errorf("openclaw stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stdout = newLogWriter(b.cfg.Logger, "[openclaw:stdout] ")
|
||||
cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
@@ -78,10 +81,10 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
// Close stderr when the context is cancelled so the scanner unblocks.
|
||||
// Close stdout when the context is cancelled so the scanner unblocks.
|
||||
go func() {
|
||||
<-runCtx.Done()
|
||||
_ = stderr.Close()
|
||||
_ = stdout.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
@@ -90,7 +93,7 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
||||
defer close(resCh)
|
||||
|
||||
startTime := time.Now()
|
||||
scanResult := b.processOutput(stderr, msgCh)
|
||||
scanResult := b.processOutput(stdout, msgCh)
|
||||
|
||||
// Wait for process exit.
|
||||
exitErr := cmd.Wait()
|
||||
@@ -202,9 +205,10 @@ type openclawEventResult struct {
|
||||
model string
|
||||
}
|
||||
|
||||
// processOutput reads the JSON output from openclaw --json stderr and returns
|
||||
// the parsed result. OpenClaw writes its JSON output to stderr, which may also
|
||||
// contain non-JSON log lines. The stream may contain:
|
||||
// processOutput reads the JSON output from openclaw --json stdout and returns
|
||||
// the parsed result. OpenClaw writes its JSON output to stdout; stderr carries
|
||||
// log overflow and is captured separately by the caller. The stream may
|
||||
// contain:
|
||||
//
|
||||
// - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error",
|
||||
// "step_start", "step_finish") — emitted in real time as the agent works
|
||||
@@ -309,12 +313,12 @@ func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclaw
|
||||
}
|
||||
|
||||
// Not JSON — treat as log line.
|
||||
b.cfg.Logger.Debug("[openclaw:stderr] " + line)
|
||||
b.cfg.Logger.Debug("[openclaw:stdout] " + line)
|
||||
rawLines = append(rawLines, line)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stderr: %v", err)}
|
||||
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stdout: %v", err)}
|
||||
}
|
||||
|
||||
// If we got no events at all, fall back to raw output.
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -234,8 +235,8 @@ func TestOpenclawProcessOutputReadError(t *testing.T) {
|
||||
if res.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "failed")
|
||||
}
|
||||
if !strings.Contains(res.errMsg, "read stderr") {
|
||||
t.Errorf("errMsg: got %q, want it to contain 'read stderr'", res.errMsg)
|
||||
if !strings.Contains(res.errMsg, "read stdout") {
|
||||
t.Errorf("errMsg: got %q, want it to contain 'read stdout'", res.errMsg)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
@@ -1163,3 +1164,67 @@ func countOccurrences(args []string, s string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestOpenclawProcessOutputStdoutFixture is the regression test for WOR-10.
|
||||
// It feeds a recorded `openclaw agent --local --json` blob (captured from
|
||||
// openclaw 2026.5.5 at the time of the fix) into processOutput exactly as
|
||||
// the swapped pipe would deliver it, and asserts the result + messages parse.
|
||||
//
|
||||
// Before the fix, the daemon read this same byte stream from stderr (where
|
||||
// nothing was written), produced "openclaw returned no parseable output",
|
||||
// and surfaced a system-typed comment to users. After the fix, processOutput
|
||||
// reads from stdout and this fixture parses cleanly.
|
||||
func TestOpenclawProcessOutputStdoutFixture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := os.ReadFile("testdata/openclaw-2026.5.5-stdout.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
if len(data) < 1000 {
|
||||
t.Fatalf("fixture too small (%d bytes); did the file get truncated?", len(data))
|
||||
}
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
res := b.processOutput(strings.NewReader(string(data)), ch)
|
||||
|
||||
if res.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", res.status, "completed")
|
||||
}
|
||||
if res.errMsg != "" {
|
||||
t.Errorf("errMsg: got %q, want empty", res.errMsg)
|
||||
}
|
||||
if res.output != "hi" {
|
||||
t.Errorf("output: got %q, want %q", res.output, "hi")
|
||||
}
|
||||
if res.sessionID == "" {
|
||||
t.Errorf("sessionID: got empty, want non-empty")
|
||||
}
|
||||
if res.model != "anthropic/claude-opus-4.7" {
|
||||
t.Errorf("model: got %q, want %q", res.model, "anthropic/claude-opus-4.7")
|
||||
}
|
||||
if res.usage.InputTokens != 34620 {
|
||||
t.Errorf("usage.InputTokens: got %d, want %d", res.usage.InputTokens, 34620)
|
||||
}
|
||||
if res.usage.OutputTokens != 6 {
|
||||
t.Errorf("usage.OutputTokens: got %d, want %d", res.usage.OutputTokens, 6)
|
||||
}
|
||||
if res.usage.CacheWriteTokens != 46482 {
|
||||
t.Errorf("usage.CacheWriteTokens: got %d, want %d", res.usage.CacheWriteTokens, 46482)
|
||||
}
|
||||
|
||||
close(ch)
|
||||
|
||||
// At least one MessageText event should have been emitted carrying "hi".
|
||||
var gotText bool
|
||||
for msg := range ch {
|
||||
if msg.Type == MessageText && strings.Contains(msg.Content, "hi") {
|
||||
gotText = true
|
||||
}
|
||||
}
|
||||
if !gotText {
|
||||
t.Errorf("expected a MessageText event containing %q", "hi")
|
||||
}
|
||||
}
|
||||
|
||||
1070
server/pkg/agent/testdata/openclaw-2026.5.5-stdout.json
vendored
Normal file
1070
server/pkg/agent/testdata/openclaw-2026.5.5-stdout.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user