Skip to content

Commit efdb91f

Browse files
authored
Merge pull request #6 from Kyzcreig/fix/observe-multi-agent
fix: OpenClaw native JSONL format support (multi-agent + toolCall parsing)
2 parents 525fd6a + 53af64a commit efdb91f

3 files changed

Lines changed: 102 additions & 34 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,9 +319,18 @@ All fields optional — sensible defaults are used when absent.
319319
| `memory/.codebook.json` | Dictionary codebook (must travel with memory files) |
320320
| `memory/.observed-sessions.json` | Tracks processed transcripts |
321321
| `memory/observations/` | Compressed session summaries |
322-
| `memory/MEMORY-L0.md` | Level 0 summary (~200 tokens) |
322+
| `memory/MEMORY-L0.md` | Level 0 summary (~200 tokens) — **see note below** |
323+
| `memory/MEMORY-L1.md` | Level 1 summary (~1000 tokens) — **see note below** |
323324
| `memory/.compactor-state.json` | Auto-compress tracking state |
324325

326+
### ⚠️ L0/L1 Warning: Hand-curate, don't auto-generate
327+
328+
The `tiers` command generates L0/L1/L2 files by scoring section headers with keyword matching. This works well for structured memory files (e.g. `MEMORY.md`) but produces **low-signal output when run against `memory/observations/`** — it fills the token budget with short, generic `[config] exec operation` sections instead of meaningful summaries.
329+
330+
**Recommendation:** Write `MEMORY-L0.md` and `MEMORY-L1.md` manually. Use `tiers` for analysis (`--json` flag) to understand your token distribution, but treat the generated files as drafts that need review before use as boot context.
331+
332+
Running `tiers` again will silently overwrite hand-curated files. If you've written your own L0/L1, either exclude them from the `tiers` output directory or don't run `tiers --output-dir` against your memory directory.
333+
325334
---
326335

327336
## Heartbeat Automation

scripts/mem_compress.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ def cmd_observe(workspace: Path, args) -> int:
182182
if not sessions_dirs:
183183
print(f"No session directories found under {sessions_base}/*/sessions/", file=sys.stderr)
184184
return 1
185-
sessions_dir = sessions_dirs[0] # primary agent
185+
# Process all agents, not just the first (fixes multi-agent setups where
186+
# sorted order may put a low-traffic agent like 'anvil' before 'main')
186187

187188
# Load tracker
188189
mem_dir = workspace / "memory"
@@ -195,8 +196,10 @@ def cmd_observe(workspace: Path, args) -> int:
195196
except (json.JSONDecodeError, OSError):
196197
tracker = {}
197198

198-
# Find session files
199-
session_files = sorted(Path(sessions_dir).glob("*.jsonl"))
199+
# Find session files across all agent dirs
200+
session_files = sorted(
201+
sf for d in sessions_dirs for sf in Path(d).glob("*.jsonl")
202+
)
200203
since = getattr(args, 'since', None)
201204

202205
new_count = 0

scripts/observation_compressor.py

Lines changed: 86 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -115,56 +115,112 @@ def parse_session_jsonl(path: Path) -> List[Dict[str, Any]]:
115115
def extract_tool_interactions(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
116116
"""Extract tool call/result pairs from parsed messages.
117117
118+
Supports OpenClaw's native JSONL format:
119+
- Tool calls: role="assistant", content block type="toolCall",
120+
fields: name (str), arguments (dict), id (str)
121+
- Tool results: role="toolResult" (top-level), fields: toolName, toolCallId,
122+
content=[{type:"text", text:"..."}]
123+
124+
Also handles legacy OpenAI-style tool_calls arrays for compatibility.
125+
118126
Returns list of interaction dicts with tool_name, input_summary, output_summary.
119127
"""
120128
interactions: List[Dict[str, Any]] = []
129+
# Index pending interactions by tool call id for result matching
130+
pending: Dict[str, Dict[str, Any]] = {}
121131

122132
for msg in messages:
123133
content = msg.get("content", "")
124134
role = msg.get("role", "")
125135

136+
# --- OpenClaw native: assistant message with toolCall content blocks ---
126137
if role == "assistant" and isinstance(content, list):
138+
# Grab any assistant text from the same message (thinking narration)
139+
assistant_text = ""
140+
for b in content:
141+
if isinstance(b, dict) and b.get("type") == "text":
142+
assistant_text = b.get("text", "")[:200]
143+
break
144+
127145
for block in content:
128-
if isinstance(block, dict) and block.get("type") == "toolCall":
129-
interaction = {
130-
"tool_name": block.get("toolName", "unknown"),
131-
"input_summary": json.dumps(block.get("input", {}))[:200],
132-
"output_summary": "",
133-
"output_size": 0,
134-
"assistant_text": "",
135-
}
136-
# Capture assistant text from the same message
137-
for b2 in content:
138-
if isinstance(b2, dict) and b2.get("type") == "text":
139-
interaction["assistant_text"] = b2.get("text", "")[:200]
140-
interactions.append(interaction)
141-
142-
# OpenAI-style tool_calls format
146+
if not (isinstance(block, dict) and block.get("type") == "toolCall"):
147+
continue
148+
tool_name = block.get("name") or block.get("toolName") or "unknown"
149+
args = block.get("arguments") or block.get("input") or {}
150+
call_id = block.get("id", "")
151+
interaction = {
152+
"tool_name": tool_name,
153+
"input_summary": json.dumps(args)[:300] if isinstance(args, dict) else str(args)[:300],
154+
"output_summary": "",
155+
"output_size": 0,
156+
"assistant_text": assistant_text,
157+
}
158+
interactions.append(interaction)
159+
if call_id:
160+
pending[call_id] = interaction
161+
162+
# --- OpenClaw native: toolResult message ---
163+
elif role == "toolResult":
164+
tool_call_id = msg.get("toolCallId", "")
165+
# Result text lives in content[0].text
166+
result_text = ""
167+
if isinstance(content, list):
168+
for block in content:
169+
if isinstance(block, dict) and block.get("type") == "text":
170+
result_text = block.get("text", "")
171+
break
172+
elif isinstance(content, str):
173+
result_text = content
174+
175+
# Match by toolCallId first, fall back to last pending
176+
target = pending.pop(tool_call_id, None)
177+
if target is None and interactions:
178+
# Fallback: attach to most recent interaction without a result
179+
for ix in reversed(interactions):
180+
if not ix["output_summary"]:
181+
target = ix
182+
break
183+
if target is not None and not target["output_summary"]:
184+
target["output_summary"] = result_text[:500]
185+
target["output_size"] = len(result_text)
186+
187+
# --- Legacy OpenAI-style tool_calls array ---
143188
elif role == "assistant" and "tool_calls" in msg:
144189
for tc in msg["tool_calls"]:
145190
func = tc.get("function", {})
146191
interaction = {
147192
"tool_name": func.get("name", "unknown"),
148-
"input_summary": func.get("arguments", "")[:200],
193+
"input_summary": func.get("arguments", "")[:300],
149194
"output_summary": "",
150195
"output_size": 0,
151196
"assistant_text": content[:200] if isinstance(content, str) else "",
152197
}
153198
interactions.append(interaction)
154-
155-
elif role == "tool" and isinstance(content, list):
156-
for block in content:
157-
if isinstance(block, dict) and block.get("type") == "toolResult":
158-
result_text = str(block.get("result", ""))
159-
# Attach to the last interaction if available
160-
if interactions and not interactions[-1]["output_summary"]:
161-
interactions[-1]["output_summary"] = result_text[:500]
162-
interactions[-1]["output_size"] = len(result_text)
163-
164-
elif role == "tool" and isinstance(content, str):
165-
if interactions and not interactions[-1]["output_summary"]:
166-
interactions[-1]["output_summary"] = content[:500]
167-
interactions[-1]["output_size"] = len(content)
199+
call_id = tc.get("id", "")
200+
if call_id:
201+
pending[call_id] = interaction
202+
203+
# --- Legacy OpenAI-style role=tool result ---
204+
elif role == "tool":
205+
tool_call_id = msg.get("tool_call_id", "")
206+
result_text = ""
207+
if isinstance(content, list):
208+
for block in content:
209+
if isinstance(block, dict):
210+
result_text = str(block.get("result") or block.get("text") or "")
211+
break
212+
elif isinstance(content, str):
213+
result_text = content
214+
215+
target = pending.pop(tool_call_id, None)
216+
if target is None and interactions:
217+
for ix in reversed(interactions):
218+
if not ix["output_summary"]:
219+
target = ix
220+
break
221+
if target is not None and not target["output_summary"]:
222+
target["output_summary"] = result_text[:500]
223+
target["output_size"] = len(result_text)
168224

169225
return interactions
170226

0 commit comments

Comments
 (0)