"""OpenClawToTraceStep — convert raw JSONL events to a structured trace dict.""" from __future__ import annotations from typing import Any from ...core.context import ACEStepContext class OpenClawToTraceStep: """Convert raw OpenClaw JSONL events into a structured trace dict. This step receives ``ctx.trace`` as a ``list[dict]`` of raw JSONL events (placed by ``LoadTracesStep``) and converts them into the trace dict format expected by ``ReflectStep``:: { "question": str, # reconstructed conversation "reasoning": str, # full execution trace (thinking + tool calls) "answer": str, # last assistant text "skill_ids": list, # always [] for OpenClaw "feedback": str, # session summary "ground_truth": None, } Follows the same pattern as ``BrowserToTrace``, ``LangChainToTrace``, and ``ClaudeCodeToTrace``. """ requires = frozenset({"trace"}) provides = frozenset({"trace"}) def __call__(self, ctx: ACEStepContext) -> ACEStepContext: events: list[dict[str, Any]] = ctx.trace # type: ignore[assignment] if not events: return ctx trace_dict = _events_to_trace(events) return ctx.replace(trace=trace_dict) def _events_to_trace(events: list[dict[str, Any]]) -> dict[str, Any]: """Convert a list of OpenClaw JSONL events into the standardised trace dict.""" user_messages: list[str] = [] assistant_texts: list[str] = [] reasoning_parts: list[str] = [] model = "" total_tokens = 0 for event in events: etype = event.get("type") if etype == "session": model = event.get("cwd", "") continue if etype == "custom": data = event.get("data", {}) if data.get("modelId"): model = data["modelId"] continue if etype != "message": continue msg = event.get("message", {}) role = msg.get("role") content_blocks = msg.get("content", []) # Track token usage usage = msg.get("usage", {}) total_tokens += usage.get("totalTokens", 0) # Track model if msg.get("model"): model = msg["model"] if role == "user": for block in content_blocks: if block.get("type") == "text": user_messages.append(block["text"]) elif role == "assistant": for block in content_blocks: btype = block.get("type") if btype == "thinking": reasoning_parts.append(f"[thinking] {block.get('thinking', '')}") elif btype == "text": text = block.get("text", "") assistant_texts.append(text) reasoning_parts.append(f"[response] {text}") elif btype == "toolCall": name = block.get("name", "unknown") args = block.get("arguments", {}) reasoning_parts.append(f"[tool:{name}] {args}") elif role == "toolResult": tool_name = msg.get("toolName", "unknown") for block in content_blocks: if block.get("type") == "text": text = block["text"] # Truncate long tool results if len(text) > 500: text = text[:500] + "..." reasoning_parts.append(f"[tool_result:{tool_name}] {text}") # Build the conversation as the "question" question = "\n\n".join(f"User: {m}" for m in user_messages) if user_messages else "" # Last assistant text as the "answer" answer = assistant_texts[-1] if assistant_texts else "" # Build feedback summary n_user = len(user_messages) n_assistant = len(assistant_texts) feedback = ( f"OpenClaw session: {n_user} user messages, {n_assistant} assistant responses" ) if model: feedback += f", model: {model}" if total_tokens: feedback += f", {total_tokens} tokens" return { "question": question, "reasoning": "\n".join(reasoning_parts), "answer": answer, "skill_ids": [], "feedback": feedback, "ground_truth": None, }