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)
|
cmd.Env = buildEnv(b.cfg.Env)
|
||||||
|
|
||||||
// openclaw writes its --json output to stderr, not stdout.
|
// openclaw writes its --json output to stdout. Stderr carries log
|
||||||
stderr, err := cmd.StderrPipe()
|
// 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 {
|
if err != nil {
|
||||||
cancel()
|
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 {
|
if err := cmd.Start(); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
@@ -78,10 +81,10 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
|||||||
msgCh := make(chan Message, 256)
|
msgCh := make(chan Message, 256)
|
||||||
resCh := make(chan Result, 1)
|
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() {
|
go func() {
|
||||||
<-runCtx.Done()
|
<-runCtx.Done()
|
||||||
_ = stderr.Close()
|
_ = stdout.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -90,7 +93,7 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
|
|||||||
defer close(resCh)
|
defer close(resCh)
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
scanResult := b.processOutput(stderr, msgCh)
|
scanResult := b.processOutput(stdout, msgCh)
|
||||||
|
|
||||||
// Wait for process exit.
|
// Wait for process exit.
|
||||||
exitErr := cmd.Wait()
|
exitErr := cmd.Wait()
|
||||||
@@ -202,9 +205,10 @@ type openclawEventResult struct {
|
|||||||
model string
|
model string
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOutput reads the JSON output from openclaw --json stderr and returns
|
// processOutput reads the JSON output from openclaw --json stdout and returns
|
||||||
// the parsed result. OpenClaw writes its JSON output to stderr, which may also
|
// the parsed result. OpenClaw writes its JSON output to stdout; stderr carries
|
||||||
// contain non-JSON log lines. The stream may contain:
|
// log overflow and is captured separately by the caller. The stream may
|
||||||
|
// contain:
|
||||||
//
|
//
|
||||||
// - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error",
|
// - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error",
|
||||||
// "step_start", "step_finish") — emitted in real time as the agent works
|
// "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.
|
// Not JSON — treat as log line.
|
||||||
b.cfg.Logger.Debug("[openclaw:stderr] " + line)
|
b.cfg.Logger.Debug("[openclaw:stdout] " + line)
|
||||||
rawLines = append(rawLines, line)
|
rawLines = append(rawLines, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
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.
|
// If we got no events at all, fall back to raw output.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -234,8 +235,8 @@ func TestOpenclawProcessOutputReadError(t *testing.T) {
|
|||||||
if res.status != "failed" {
|
if res.status != "failed" {
|
||||||
t.Errorf("status: got %q, want %q", res.status, "failed")
|
t.Errorf("status: got %q, want %q", res.status, "failed")
|
||||||
}
|
}
|
||||||
if !strings.Contains(res.errMsg, "read stderr") {
|
if !strings.Contains(res.errMsg, "read stdout") {
|
||||||
t.Errorf("errMsg: got %q, want it to contain 'read stderr'", res.errMsg)
|
t.Errorf("errMsg: got %q, want it to contain 'read stdout'", res.errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
close(ch)
|
close(ch)
|
||||||
@@ -1163,3 +1164,67 @@ func countOccurrences(args []string, s string) int {
|
|||||||
}
|
}
|
||||||
return n
|
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