Files
multica/server/pkg/agent/openclaw.go
Joey Frasier (Boothe) 76cd8275ff fix(openclaw): parse whole buffer instead of line-by-line scanner (MUL-1908) (#2292)
* fix(openclaw): parse whole buffer instead of line-by-line scanner

Follow-up to c87d7676 (WOR-10). The stdout/stderr swap fixed the dominant
case but `processOutput` still scanned line-by-line and only attempted a
whole-buffer parse from a fragile fallback path. Pretty-printed JSON
(openclaw 2026.5.x emits the result blob indented across many lines) made
every individual line unparseable on its own — `{`, `  "payloads": [`,
`    {`, etc. — so the success path hinged entirely on the fallback
joining `rawLines` and re-trying.

Under load (daemon restarts racing the close-on-cancel goroutine, partial
chunked reads when stdout closes mid-flight) the line scanner could see
truncated input that never reassembled into valid JSON, surfacing
"openclaw returned no parseable output" against runs where the agent had
in fact completed the work and posted comments. Roughly 30–40% of recent
runs in v0.2.27 logs hit this path; multica still wrote a `task_failed`
inbox row for each one even though the underlying issue had moved to
`in_review` or `done`.

The fix:

- processOutput now reads the full stdout buffer with `io.ReadAll` first.
- A new `parseWholeBufferOpenclawResult` helper attempts a single
  `json.Unmarshal` against the entire buffer (after trimming, and after
  optionally stripping leading non-JSON log lines). When it matches, we
  build the result and return — the line scanner never runs.
- If the whole-buffer parse fails, we fall through to the existing NDJSON
  line-by-line scanner. This preserves streaming-event support (kept for
  forward compatibility and other backends) without leaving openclaw's
  dominant pretty-printed shape at the mercy of timing.
- The failure path now emits a `(got N bytes; preview: ...)` suffix on
  the canonical "no parseable output" error so future debugging isn't
  blind. The exact canonical phrase is preserved for empty buffers so
  existing dashboards / log-grep tooling keep matching.

Tests:

- TestOpenclawProcessOutputWholeBufferPrettyJSON: feeds a hand-crafted
  multi-line indented blob (multiple payloads, nested agentMeta, usage
  map) and asserts every field round-trips through the whole-buffer fast
  path.
- TestOpenclawProcessOutputDeeplyIndentedFixture: re-runs the recorded
  openclaw 2026.5.5 stdout fixture (1070 lines) directly through
  parseWholeBufferOpenclawResult, asserting the bug-shape parses cleanly
  on the first attempt without falling through to NDJSON scanning.
- TestOpenclawProcessOutputEmptyBufferErrorIncludesByteCount: tightens
  the empty-buffer failure path, asserts the canonical phrase survives so
  observability tooling keeps working.

All existing tests in the openclaw + buildOpenclawArgs suites stay green
(streaming NDJSON event tests, lifecycle tests, structured-error tests,
usage-field-variant tests). The two pre-existing flaky timeout-tight
codex tests (TestCodexExecuteSemanticInactivityAllowsContinuous*) fail on
both this branch and on c87d7676 baseline; they are unrelated and out of
scope here.

Co-authored-by: multica-agent <github@multica.ai>

* fix(openclaw): drop dead preview branch, document streaming regression

Rebase + review-fix follow-up on top of f27df2d9b.

processOutput's preview branch was unreachable: openclawNoParseableOutputError
was only called from the `!gotEvents && trimmed == ""` path, which by
construction means the entire scanned buffer collapsed to whitespace, so the
`(got N bytes; preview: ...)` formatter could never fire on a non-empty buffer.
Replace the helper with a single canonical-string constant (callsite is now
inline) and update the test name to match what it actually asserts (the
canonical empty-buffer error string is preserved for external log-grep /
dashboard consumers).

Also document on processOutput that the line-scanner path is no longer
truly streaming after the io.ReadAll switch: events accumulate until
stdout closes. OpenClaw 2026.5.x does not emit streaming events so this
regression is invisible today, but flag it for the next backend that
might.

Misc: switch the scanner's input source from
`strings.NewReader(string(buf))` to `bytes.NewReader(buf)` to drop one
unnecessary byte/string round-trip.

MUL-1908

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J (Multica agent) <j@multica.local>
2026-05-19 17:42:41 +08:00

693 lines
24 KiB
Go

package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// openclawNoParseableOutput is the canonical error string surfaced when the
// adapter cannot extract any usable JSON from a run's stdout. The exact
// phrase is depended on by external log-grep / dashboard alerts; do not
// change it without also updating those consumers.
const openclawNoParseableOutput = "openclaw returned no parseable output"
// minOpenclawVersion is the lowest openclaw version that emits its
// --json result on stdout. PR #2101 swapped the adapter from reading
// stderr to stdout; older builds wrote JSON to stderr and now appear
// to silently produce no output. The check in Execute fails fast with
// a hardcoded upgrade hint so users see an actionable message instead
// of "openclaw returned no parseable output".
const minOpenclawVersion = "2026.5.5"
// openclawVersionPattern extracts a three-segment dotted version from
// arbitrary `openclaw --version` output (e.g. "openclaw 2026.5.5",
// "openclaw v2026.5.5 c37871e").
var openclawVersionPattern = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`)
// openclawBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args.
var openclawBlockedArgs = map[string]blockedArgMode{
"--local": blockedStandalone, // local mode for daemon execution
"--json": blockedStandalone, // JSON output for daemon communication
"--session-id": blockedWithValue, // managed by daemon for session resumption
"--message": blockedWithValue, // prompt is set by daemon
"--model": blockedWithValue, // openclaw agent does not accept --model; model is bound at registration via `openclaw agents add/update --model`
"--system-prompt": blockedWithValue, // openclaw agent does not accept --system-prompt; instructions are injected into --message
}
// 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)
}
if err := checkOpenclawVersion(ctx, execPath); err != nil {
return nil, 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 := buildOpenclawArgs(prompt, sessionID, opts, b.cfg.Logger)
cmd := exec.CommandContext(runCtx, execPath, args...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
// 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 stdout pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ")
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)
// Close stdout when the context is cancelled so the scanner unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
startTime := time.Now()
scanResult := b.processOutput(stdout, 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. Prefer the model openclaw reported in
// `meta.agentMeta.model` (the actual LLM, e.g. `deepseek-chat`).
// Fall back to opts.Model — which for openclaw is the agent name
// passed via `--agent`, not a real model identifier — only when
// the runtime didn't surface its own model. Last resort is the
// daemon's `unknown` placeholder.
var usage map[string]TokenUsage
u := scanResult.usage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
model := scanResult.model
if model == "" {
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
}
// buildOpenclawArgs assembles the argv for a one-shot `openclaw agent` invocation.
//
// The CLI only accepts --local, --json, --session-id, --timeout, --message (and
// flags like --agent / --channel that users pass through CustomArgs). Notably
// it does NOT accept --model or --system-prompt — model is bound at agent
// registration time via `openclaw agents add/update --model`, and instructions
// must be injected inline into --message because openclaw loads AGENTS.md from
// its own workspace directory, not from cwd.
func buildOpenclawArgs(prompt, sessionID string, opts ExecOptions, logger *slog.Logger) []string {
args := []string{"agent", "--local", "--json", "--session-id", sessionID}
if opts.Timeout > 0 {
args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())))
}
// OpenClaw binds models to pre-registered agents at `openclaw agents
// add/update --model` time; the daemon selects one at runtime by
// passing --agent <id>. The model dropdown populates its list from
// `openclaw agents list`, so opts.Model here is an agent id (see
// openclawEntriesToModels — the agent's display name lives in the
// dropdown label, not in opts.Model). Only inject when the user
// hasn't already set --agent via custom_args — custom_args wins for
// backward compatibility with existing configs.
customArgs := filterCustomArgs(opts.CustomArgs, openclawBlockedArgs, logger)
if opts.Model != "" && !customArgsContains(customArgs, "--agent") {
args = append(args, "--agent", opts.Model)
}
args = append(args, customArgs...)
if opts.SystemPrompt != "" {
prompt = opts.SystemPrompt + "\n\n" + prompt
}
args = append(args, "--message", prompt)
return args
}
// customArgsContains reports whether args contains the given flag
// (either as a standalone token "--flag" or in "--flag=value" form).
func customArgsContains(args []string, flag string) bool {
prefix := flag + "="
for _, a := range args {
if a == flag || strings.HasPrefix(a, prefix) {
return true
}
}
return false
}
// checkOpenclawVersion runs `<execPath> --version` and returns a
// user-facing error when the installed openclaw is older than
// minOpenclawVersion. The returned error becomes the task's failure
// comment, so the message intentionally names the detected version
// and the upgrade command.
func checkOpenclawVersion(ctx context.Context, execPath string) error {
cmd := exec.CommandContext(ctx, execPath, "--version")
hideAgentWindow(cmd)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("openclaw --version failed: %w", err)
}
detected, ok := parseOpenclawVersion(string(out))
if !ok {
return fmt.Errorf("could not parse openclaw version from output: %q", strings.TrimSpace(string(out)))
}
if compareOpenclawVersion(detected, minOpenclawVersion) < 0 {
return fmt.Errorf("openclaw %s is below the minimum supported version %s. Run `openclaw update` to upgrade and try again.", detected, minOpenclawVersion)
}
return nil
}
// parseOpenclawVersion extracts the first three-segment dotted version
// from arbitrary `openclaw --version` output. Returns ok=false when no
// match is found.
func parseOpenclawVersion(raw string) (string, bool) {
m := openclawVersionPattern.FindString(raw)
if m == "" {
return "", false
}
return m, true
}
// compareOpenclawVersion compares two three-segment dotted versions
// numerically. Returns -1, 0, or +1 like bytes.Compare. Inputs must be
// well-formed (matched by openclawVersionPattern); malformed segments
// compare as zero.
func compareOpenclawVersion(a, b string) int {
aParts := strings.SplitN(a, ".", 3)
bParts := strings.SplitN(b, ".", 3)
for i := 0; i < 3; i++ {
ai, _ := strconv.Atoi(aParts[i])
bi, _ := strconv.Atoi(bParts[i])
if ai < bi {
return -1
}
if ai > bi {
return 1
}
}
return 0
}
// ── Event handlers ──
// openclawEventResult holds accumulated state from processing the event stream.
type openclawEventResult struct {
status string
errMsg string
output string
sessionID string
usage TokenUsage
// model is the LLM identifier reported by openclaw in its result blob
// (`meta.agentMeta.model`). Empty when the run did not emit it (older
// openclaw versions, partial outputs). Distinct from `opts.Model`,
// which for the openclaw backend is the openclaw *agent* name passed
// via `--agent`, not the underlying model.
model string
}
// 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:
//
// - A final result JSON (with payloads + meta) — the format openclaw 2026.5.x
// emits today, typically pretty-printed across many lines
// - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error",
// "step_start", "step_finish") — supported for forward compatibility and
// other backends sharing this code path; openclaw does not emit these today
//
// Implementation note (WOR-10 follow-up): we previously scanned line-by-line
// only, then tried a whole-buffer parse in a fallback path. Under load
// (daemon shutdown racing the scanner, partial chunked reads) the line
// scanner could see truncated input that never reassembled, surfacing the
// generic "openclaw returned no parseable output" error even though the
// agent's work succeeded. We now read the full buffer first and try a
// single whole-buffer parse against the final-result schema. Only if that
// fails do we fall through to the line-by-line NDJSON scanner. This makes
// the dominant happy path (one pretty-printed JSON blob) deterministic
// while keeping NDJSON event support intact.
func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclawEventResult {
buf, readErr := io.ReadAll(r)
if readErr != nil {
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stdout: %v", readErr)}
}
// Whole-buffer fast path: openclaw 2026.5.x emits a single pretty-printed
// JSON result blob. Try parsing the entire buffer (after trimming whitespace
// and any preceding non-JSON log lines) as the final-result schema. If it
// matches, we're done — no need to involve the line scanner at all.
if result, ok := parseWholeBufferOpenclawResult(buf); ok {
var output strings.Builder
return b.buildOpenclawEventResult(result, ch, &output)
}
// Fall-back path: NDJSON line scanner. Note that because we already
// drained the full buffer with io.ReadAll above, this path is no longer
// truly streaming — events accumulate until the subprocess closes
// stdout, then drain all at once. OpenClaw 2026.5.x does not emit
// streaming events, so this regression is invisible today; if a future
// backend on this code path emits real NDJSON streams and needs live
// progress updates, we'll need to split the fast path off a streaming
// reader instead of io.ReadAll.
scanner := bufio.NewScanner(bytes.NewReader(buf))
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
var output strings.Builder
var sessionID string
var model string
var usage TokenUsage
finalStatus := "completed"
var finalError string
gotEvents := false // true if we parsed at least one streaming event or result
var rawLines []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Try parsing as a streaming NDJSON event first.
if event, ok := tryParseOpenclawEvent(line); ok {
gotEvents = true
if event.SessionID != "" {
sessionID = event.SessionID
}
switch event.Type {
case "text":
if event.Text != "" {
output.WriteString(event.Text)
trySend(ch, Message{Type: MessageText, Content: event.Text})
}
case "tool_use":
var input map[string]any
if event.Input != nil {
_ = json.Unmarshal(event.Input, &input)
}
trySend(ch, Message{
Type: MessageToolUse,
Tool: event.Tool,
CallID: event.CallID,
Input: input,
})
case "tool_result":
trySend(ch, Message{
Type: MessageToolResult,
Tool: event.Tool,
CallID: event.CallID,
Output: event.Text,
})
case "error":
errMsg := event.errorMessage()
b.cfg.Logger.Warn("openclaw error event", "error", errMsg)
trySend(ch, Message{Type: MessageError, Content: errMsg})
finalStatus = "failed"
finalError = errMsg
case "lifecycle":
phase := event.Phase
if phase == "error" || phase == "failed" || phase == "cancelled" {
errMsg := event.errorMessage()
b.cfg.Logger.Warn("openclaw lifecycle failure", "phase", phase, "error", errMsg)
trySend(ch, Message{Type: MessageError, Content: errMsg})
finalStatus = "failed"
finalError = errMsg
}
case "step_start":
trySend(ch, Message{Type: MessageStatus, Status: "running"})
case "step_finish":
if event.Usage != nil {
u := parseOpenclawUsage(event.Usage)
usage.InputTokens += u.InputTokens
usage.OutputTokens += u.OutputTokens
usage.CacheReadTokens += u.CacheReadTokens
usage.CacheWriteTokens += u.CacheWriteTokens
}
}
continue
}
// Try parsing as a final result blob (legacy format).
if result, ok := tryParseOpenclawResult(line); ok {
gotEvents = true
res := b.buildOpenclawEventResult(result, ch, &output)
if res.sessionID != "" {
sessionID = res.sessionID
}
if res.model != "" {
model = res.model
}
// Prefer usage from the final result if no streaming events reported it.
u := res.usage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
usage = u
}
continue
}
// Not JSON — treat as log 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 stdout: %v", err)}
}
// If we got no events at all, fall back to raw output. The whole-buffer
// fast path above already tried the structured-result parse — by the time
// we reach here the buffer truly is unstructured (just log lines, plain
// text, or empty). Surface the trimmed text as a completed run when we
// have any, otherwise the canonical no-parseable-output failure.
if !gotEvents {
trimmed := strings.TrimSpace(strings.Join(rawLines, "\n"))
if trimmed != "" {
return openclawEventResult{status: "completed", output: trimmed}
}
return openclawEventResult{
status: "failed",
errMsg: openclawNoParseableOutput,
}
}
return openclawEventResult{
status: finalStatus,
errMsg: finalError,
output: output.String(),
sessionID: sessionID,
usage: usage,
model: model,
}
}
// parseWholeBufferOpenclawResult attempts to parse the entire stdout buffer
// as a single openclaw final-result JSON blob (the format openclaw 2026.5.x
// emits today, almost always pretty-printed across multiple lines).
//
// It first tries the buffer as-is, then strips any leading non-JSON log
// lines (lines that don't start with '{' at column 0) so a daemon log
// preamble doesn't defeat the parse. It does NOT scan into the middle of
// log lines: only line starts that begin with '{' are considered candidate
// JSON entry points, mirroring the conservative behaviour of
// tryParseOpenclawResult.
func parseWholeBufferOpenclawResult(buf []byte) (openclawResult, bool) {
trimmed := strings.TrimSpace(string(buf))
if trimmed == "" {
return openclawResult{}, false
}
if result, ok := tryParseOpenclawResult(trimmed); ok {
return result, true
}
// Strip any leading log lines that precede the JSON blob.
lines := strings.Split(trimmed, "\n")
for i, line := range lines {
if len(line) > 0 && line[0] == '{' {
candidate := strings.TrimSpace(strings.Join(lines[i:], "\n"))
if result, ok := tryParseOpenclawResult(candidate); ok {
return result, true
}
return openclawResult{}, false
}
}
return openclawResult{}, false
}
// tryParseOpenclawEvent attempts to parse a line as a streaming NDJSON event.
// Returns the event and true if the line is a valid event with a known type.
func tryParseOpenclawEvent(line string) (openclawEvent, bool) {
if len(line) == 0 || line[0] != '{' {
return openclawEvent{}, false
}
var event openclawEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
return openclawEvent{}, false
}
if event.Type == "" {
return openclawEvent{}, false
}
return event, true
}
// tryParseOpenclawResult attempts to parse a line as a final result blob
// (the legacy format with payloads + meta). Lines must start with '{' to be
// considered — we no longer scan for braces at arbitrary positions, which
// avoids false matches on log lines containing JSON fragments.
func tryParseOpenclawResult(raw string) (openclawResult, bool) {
if len(raw) == 0 || raw[0] != '{' {
return openclawResult{}, false
}
var result openclawResult
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return openclawResult{}, false
}
if result.Payloads == nil && result.Meta.DurationMs == 0 {
return openclawResult{}, false
}
return result, true
}
// buildOpenclawEventResult extracts text and metadata from a final result blob.
// Text payloads are appended to the shared output builder and emitted to ch.
func (b *openclawBackend) buildOpenclawEventResult(result openclawResult, ch chan<- Message, output *strings.Builder) openclawEventResult {
for _, p := range result.Payloads {
if p.Text != "" {
output.WriteString(p.Text)
trySend(ch, Message{Type: MessageText, Content: p.Text})
}
}
var sessionID string
var model string
var usage TokenUsage
if result.Meta.AgentMeta != nil {
if sid, ok := result.Meta.AgentMeta["sessionId"].(string); ok {
sessionID = sid
}
// `meta.agentMeta.model` is openclaw's true LLM identifier
// (e.g. "deepseek-chat", "claude-sonnet-4"). Take it as-is — the
// dashboard expects whatever string the runtime reports, mirroring
// claude/pi/codex which read model directly off their stream.
if m, ok := result.Meta.AgentMeta["model"].(string); ok {
model = strings.TrimSpace(m)
}
if u, ok := result.Meta.AgentMeta["usage"].(map[string]any); ok {
usage = parseOpenclawUsage(u)
}
}
return openclawEventResult{
status: "completed",
output: output.String(),
sessionID: sessionID,
usage: usage,
model: model,
}
}
// parseOpenclawUsage extracts token usage from a map, supporting multiple
// field name conventions used by different OpenClaw versions and PaperClip:
//
// input / inputTokens / input_tokens
// output / outputTokens / output_tokens
// cacheRead / cachedInputTokens / cached_input_tokens / cache_read
// cacheWrite / cacheCreationInputTokens / cache_creation_input_tokens / cache_write
func parseOpenclawUsage(data map[string]any) TokenUsage {
return TokenUsage{
InputTokens: openclawInt64FirstOf(data, "input", "inputTokens", "input_tokens"),
OutputTokens: openclawInt64FirstOf(data, "output", "outputTokens", "output_tokens"),
CacheReadTokens: openclawInt64FirstOf(data, "cacheRead", "cachedInputTokens", "cached_input_tokens", "cache_read", "cache_read_input_tokens"),
CacheWriteTokens: openclawInt64FirstOf(data, "cacheWrite", "cacheCreationInputTokens", "cache_creation_input_tokens", "cache_write"),
}
}
// openclawInt64FirstOf returns the first non-zero int64 value found under any
// of the given keys. This supports field name variants across protocol versions.
func openclawInt64FirstOf(data map[string]any, keys ...string) int64 {
for _, key := range keys {
if v := openclawInt64(data, key); v != 0 {
return v
}
}
return 0
}
// 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 ──
// openclawEvent represents a single streaming NDJSON event from openclaw --json.
//
// Event types:
// - "text" — text output (text field)
// - "tool_use" — tool invocation (tool, callId, input)
// - "tool_result" — tool output (tool, callId, text)
// - "error" — error (text, or structured error object)
// - "lifecycle" — phase changes (phase: "error"/"failed"/"cancelled")
// - "step_start" — agent step begins
// - "step_finish" — agent step ends (usage)
type openclawEvent struct {
Type string `json:"type"`
SessionID string `json:"sessionId,omitempty"`
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
CallID string `json:"callId,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Usage map[string]any `json:"usage,omitempty"`
Phase string `json:"phase,omitempty"` // lifecycle event phase
Error *openclawError `json:"error,omitempty"` // structured error object
Message string `json:"message,omitempty"` // alternative error message field
}
// errorMessage extracts a human-readable error message from the event,
// checking multiple fields: structured error object, text, message, or fallback.
func (e openclawEvent) errorMessage() string {
if e.Error != nil {
if msg := e.Error.message(); msg != "" {
return msg
}
}
if e.Text != "" {
return e.Text
}
if e.Message != "" {
return e.Message
}
return "unknown openclaw error"
}
// openclawError represents a structured error in an openclaw event,
// compatible with PaperClip's error format (name + data.message).
type openclawError struct {
Name string `json:"name,omitempty"`
Data *openclawErrorData `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
func (e *openclawError) message() string {
if e.Data != nil && e.Data.Message != "" {
return e.Data.Message
}
if e.Message != "" {
return e.Message
}
if e.Name != "" {
return e.Name
}
return ""
}
type openclawErrorData struct {
Message string `json:"message,omitempty"`
}
// openclawResult represents the final JSON output from `openclaw agent --json`
// (the legacy single-blob format with payloads + meta).
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"`
}