@giamme/forge 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""What an agent CLI said and did in one execution: its log read as one list of events.
|
|
2
|
+
|
|
3
|
+
forge sends each dispatch's output to `<role>.log` (forge-dispatch.sh). What that holds depends on the
|
|
4
|
+
harness and how forge called it:
|
|
5
|
+
- codex `--json`: one JSON event per line (thread, items, turns);
|
|
6
|
+
- grok `--output-format streaming-messages-json`, claude and openclaude `--output-format stream-json`: one
|
|
7
|
+
message per line in Claude's shape (system, assistant, user, result);
|
|
8
|
+
- opencode `--format json`: one event per line (text, reasoning, tool_use, step_finish, error);
|
|
9
|
+
- claude `--output-format json`: only the final result, with the session id; the conversation itself is
|
|
10
|
+
then read from Claude's session file, `<config dir>/projects/<project>/<session id>.jsonl`;
|
|
11
|
+
- anything else (agy, plain text runs): the log as it is.
|
|
12
|
+
The format is told from the log itself; the harness only says where a session file would be.
|
|
13
|
+
|
|
14
|
+
An event is a dict with `kind` and the keys that kind uses:
|
|
15
|
+
prompt text the task as the agent got it (a session file's first message)
|
|
16
|
+
message text what the agent wrote
|
|
17
|
+
reasoning text its thinking, when the harness shows it
|
|
18
|
+
command command, output, exit, error a shell command and what it printed
|
|
19
|
+
edit files, tool, error files the agent wrote or changed
|
|
20
|
+
tool tool, target, input, output, error any other tool call (target: the file, pattern or url it is about)
|
|
21
|
+
result text, tokens, error the final answer (and the run's totals)
|
|
22
|
+
error text an error the harness reported
|
|
23
|
+
text text, stream lines that are not structured (a plain log, stderr)
|
|
24
|
+
gap count events left out of the middle of a very long transcript
|
|
25
|
+
Every event may carry `t` (epoch seconds), `id` (the harness's own id for a tool call), `error` (true for a call
|
|
26
|
+
that failed, false for one answered without an error) and, while the execution runs, `running` (a call not
|
|
27
|
+
answered yet). Texts are capped.
|
|
28
|
+
Nothing here opens any path but the log given and, for a Claude result, that one session file.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
import re
|
|
37
|
+
import stat
|
|
38
|
+
|
|
39
|
+
TEXT_CAP = 20000 # characters kept of one message, reasoning or result
|
|
40
|
+
OUTPUT_CAP = 8000 # of a command's or tool's output: the head and the tail
|
|
41
|
+
LOG_CAP = 32 << 20 # bytes of a log read; a longer one is read from its end
|
|
42
|
+
EVENTS_CAP = 5000 # events answered, and characters in them: past either, the middle is left out
|
|
43
|
+
CHARS_CAP = 4_000_000
|
|
44
|
+
_SESSION_ID = re.compile(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')
|
|
45
|
+
_ANSI = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
|
46
|
+
_SHELL = re.compile(r"(?:/\S+/)?(?:ba|z)?sh -l?c '((?:[^']|'\\'')*)'", re.S) # codex's wrapper around a command
|
|
47
|
+
_TARGETS = ('file_path', 'filePath', 'target_file', 'notebook_path', 'path', 'target_directory', 'pattern', 'query', 'url')
|
|
48
|
+
_SHELL_TOOLS = frozenset(('bash', 'shell', 'run_terminal_command', 'run_shell_command', 'exec_command', 'terminal'))
|
|
49
|
+
_EDIT_TOOLS = frozenset(('edit', 'write', 'multiedit', 'notebookedit', 'search_replace', 'write_file', 'edit_file',
|
|
50
|
+
'create_file', 'str_replace_editor', 'apply_patch', 'patch', 'replace', 'write_to_file'))
|
|
51
|
+
_CACHE: dict = {} # (path, size, mtime, ...) → (answer, session file it read); a live log is read again when it grew
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cap(text, limit):
|
|
55
|
+
"""`text` as a string of at most `limit` characters: its head and tail around a marker, and how much was cut."""
|
|
56
|
+
s = text if isinstance(text, str) else json.dumps(text, ensure_ascii=False, indent=1) if text is not None else ''
|
|
57
|
+
if len(s) <= limit:
|
|
58
|
+
return s, 0
|
|
59
|
+
head = limit * 2 // 3
|
|
60
|
+
tail = limit - head
|
|
61
|
+
return s[:head] + f'\n… {len(s) - limit} characters …\n' + s[-tail:], len(s) - limit
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _put(event, key, value, limit):
|
|
65
|
+
if value is None or value == '':
|
|
66
|
+
return
|
|
67
|
+
text, cut = cap(value, limit)
|
|
68
|
+
event[key] = text
|
|
69
|
+
if cut:
|
|
70
|
+
event[key + '_cut'] = cut
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _event(kind, **fields):
|
|
74
|
+
out = {'kind': kind}
|
|
75
|
+
for key, value in fields.items():
|
|
76
|
+
if key in ('text', 'output', 'input', 'command') and value is not None:
|
|
77
|
+
_put(out, key, value, OUTPUT_CAP if key in ('output', 'input') else TEXT_CAP)
|
|
78
|
+
elif value is not None:
|
|
79
|
+
out[key] = value
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unwrap(command):
|
|
84
|
+
"""A command without the `bash -lc '…'` codex runs it in."""
|
|
85
|
+
m = _SHELL.fullmatch(command) if isinstance(command, str) else None
|
|
86
|
+
return m.group(1).replace("'\\''", "'") if m else command
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _tool_event(name, args, ident=None, t=None):
|
|
90
|
+
"""A tool call as the event it is: a shell command, an edit, or any other tool."""
|
|
91
|
+
low = (name or '').lower()
|
|
92
|
+
args = args if isinstance(args, dict) else {'input': args} if args is not None else {}
|
|
93
|
+
if low in _SHELL_TOOLS:
|
|
94
|
+
return _event('command', command=args.get('command') or args.get('cmd') or args.get('input'), tool=name, id=ident, t=t)
|
|
95
|
+
if low in _EDIT_TOOLS:
|
|
96
|
+
files = [args.get(k) for k in ('file_path', 'filePath', 'path', 'target_file', 'notebook_path') if isinstance(args.get(k), str)]
|
|
97
|
+
if not files and isinstance(args.get('edits'), list):
|
|
98
|
+
files = [e.get('file_path') or e.get('path') for e in args['edits'] if isinstance(e, dict)]
|
|
99
|
+
patch = _patch(args)
|
|
100
|
+
ev = _event('edit', tool=name, files=[f for f in files if f] or None, input=None if patch else args, id=ident, t=t)
|
|
101
|
+
_put(ev, 'patch', patch, OUTPUT_CAP)
|
|
102
|
+
return ev
|
|
103
|
+
# what the call is about (a file, a pattern, a url) heads it; the other arguments follow
|
|
104
|
+
target = next((k for k in _TARGETS if isinstance(args.get(k), str)), None)
|
|
105
|
+
rest = {k: v for k, v in args.items() if k != target}
|
|
106
|
+
return _event('tool', tool=name or 'tool', target=args[target] if target else None, input=rest or None, id=ident, t=t)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _patch(args):
|
|
110
|
+
"""An edit's arguments as -/+ lines: old and new strings (each edit of a multi-edit), or a written file's content."""
|
|
111
|
+
def pair(a):
|
|
112
|
+
old, new = a.get('old_string', a.get('oldString')), a.get('new_string', a.get('newString'))
|
|
113
|
+
if not isinstance(old, str) and not isinstance(new, str):
|
|
114
|
+
return None
|
|
115
|
+
return '\n'.join(['-' + line for line in (old or '').splitlines()] + ['+' + line for line in (new or '').splitlines()])
|
|
116
|
+
parts = [pair(args)] + [pair(e) for e in args.get('edits') or [] if isinstance(e, dict)]
|
|
117
|
+
parts = [part for part in parts if part]
|
|
118
|
+
if parts:
|
|
119
|
+
return '\n…\n'.join(parts)
|
|
120
|
+
content = args.get('content')
|
|
121
|
+
return '\n'.join('+' + line for line in content.splitlines()) if isinstance(content, str) else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _when(value):
|
|
125
|
+
"""Epoch seconds from an ISO timestamp or epoch milliseconds/seconds, else None."""
|
|
126
|
+
if isinstance(value, (int, float)):
|
|
127
|
+
return value / 1000 if value > 1e11 else float(value)
|
|
128
|
+
if isinstance(value, str) and value:
|
|
129
|
+
try:
|
|
130
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp()
|
|
131
|
+
except ValueError:
|
|
132
|
+
return None
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _tokens(total, usage):
|
|
137
|
+
"""Adds a usage block (Claude, codex, opencode or grok naming) to the running totals."""
|
|
138
|
+
if not isinstance(usage, dict):
|
|
139
|
+
return
|
|
140
|
+
def num(*keys):
|
|
141
|
+
for k in keys:
|
|
142
|
+
v = usage.get(k)
|
|
143
|
+
if isinstance(v, (int, float)):
|
|
144
|
+
return v
|
|
145
|
+
return 0
|
|
146
|
+
cache = usage.get('cache') if isinstance(usage.get('cache'), dict) else {}
|
|
147
|
+
total['input'] = total.get('input', 0) + num('input_tokens', 'input') + num('cache_creation_input_tokens') + num('cache_read_input_tokens') + (cache.get('read') or 0) + (cache.get('write') or 0)
|
|
148
|
+
total['cached'] = total.get('cached', 0) + num('cached_input_tokens', 'cache_read_input_tokens') + (cache.get('read') or 0)
|
|
149
|
+
total['output'] = total.get('output', 0) + num('output_tokens', 'output') + num('reasoning')
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------- the formats ----------
|
|
153
|
+
|
|
154
|
+
def _lines(text):
|
|
155
|
+
"""(json or None, raw line) for each non-empty line."""
|
|
156
|
+
for raw in text.splitlines():
|
|
157
|
+
line = raw.strip()
|
|
158
|
+
if not line:
|
|
159
|
+
continue
|
|
160
|
+
value = None
|
|
161
|
+
if line[0] == '{':
|
|
162
|
+
try:
|
|
163
|
+
value = json.loads(line)
|
|
164
|
+
except ValueError:
|
|
165
|
+
value = None
|
|
166
|
+
yield (value if isinstance(value, dict) else None), raw
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def detect(text):
|
|
170
|
+
"""The log's format: codex, claude (Claude's message shape), claude-result, opencode, json or text."""
|
|
171
|
+
seen = 0
|
|
172
|
+
for value, _ in _lines(text[:200000]):
|
|
173
|
+
if value is None:
|
|
174
|
+
continue
|
|
175
|
+
seen += 1
|
|
176
|
+
kind = value.get('type')
|
|
177
|
+
if 'item' in value or kind in ('thread.started', 'turn.started', 'turn.completed', 'turn.failed'):
|
|
178
|
+
return 'codex'
|
|
179
|
+
if kind in ('step_start', 'step_finish', 'tool_use', 'text', 'reasoning') and ('part' in value or 'sessionID' in value):
|
|
180
|
+
return 'opencode'
|
|
181
|
+
if kind in ('system', 'assistant', 'user') and ('message' in value or 'subtype' in value or 'session_id' in value):
|
|
182
|
+
return 'claude'
|
|
183
|
+
if kind == 'result' and seen == 1 and 'session_id' in value:
|
|
184
|
+
rest = [v for v, _ in _lines(text) if v is not None]
|
|
185
|
+
return 'claude-result' if len(rest) == 1 else 'claude'
|
|
186
|
+
if seen >= 50:
|
|
187
|
+
break
|
|
188
|
+
if not seen and (_whole(text) or {}).get('type') == 'result':
|
|
189
|
+
return 'claude-result'
|
|
190
|
+
return 'json' if seen else 'text'
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _whole(text):
|
|
194
|
+
"""The log as one JSON object (a result printed over several lines), or None."""
|
|
195
|
+
body = text.strip()
|
|
196
|
+
if body[:1] != '{':
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
value = json.loads(body)
|
|
200
|
+
except ValueError:
|
|
201
|
+
return None
|
|
202
|
+
return value if isinstance(value, dict) else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _stderr(events, pending):
|
|
206
|
+
if pending:
|
|
207
|
+
events.append(_event('text', text=_ANSI.sub('', '\n'.join(pending)), stream='stderr'))
|
|
208
|
+
pending.clear()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_codex(text):
|
|
212
|
+
events, by_id, tokens, meta, pending = [], {}, {}, {}, []
|
|
213
|
+
for value, raw in _lines(text):
|
|
214
|
+
if value is None:
|
|
215
|
+
pending.append(raw)
|
|
216
|
+
continue
|
|
217
|
+
_stderr(events, pending)
|
|
218
|
+
kind = value.get('type')
|
|
219
|
+
if kind == 'thread.started':
|
|
220
|
+
meta['session'] = value.get('thread_id')
|
|
221
|
+
elif kind in ('item.started', 'item.updated', 'item.completed'):
|
|
222
|
+
item = value.get('item') or {}
|
|
223
|
+
ev = _codex_item(item)
|
|
224
|
+
if ev is None:
|
|
225
|
+
continue
|
|
226
|
+
if kind != 'item.completed':
|
|
227
|
+
ev['running'] = True
|
|
228
|
+
ident = item.get('id')
|
|
229
|
+
if ident in by_id: # started, then completed: the same place in the list
|
|
230
|
+
events[by_id[ident]] = ev
|
|
231
|
+
else:
|
|
232
|
+
if ident:
|
|
233
|
+
by_id[ident] = len(events)
|
|
234
|
+
events.append(ev)
|
|
235
|
+
elif kind == 'turn.completed':
|
|
236
|
+
_tokens(tokens, value.get('usage'))
|
|
237
|
+
elif kind == 'turn.failed':
|
|
238
|
+
events.append(_event('error', text=(value.get('error') or {}).get('message') or 'the turn failed'))
|
|
239
|
+
elif kind == 'error':
|
|
240
|
+
events.append(_event('error', text=value.get('message') or 'error'))
|
|
241
|
+
_stderr(events, pending)
|
|
242
|
+
return events, tokens, meta
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _codex_item(item):
|
|
246
|
+
kind = item.get('type') or item.get('item_type')
|
|
247
|
+
if kind in ('agent_message', 'assistant_message'):
|
|
248
|
+
return _event('message', text=item.get('text'))
|
|
249
|
+
if kind == 'reasoning':
|
|
250
|
+
return _event('reasoning', text=item.get('text') or item.get('summary'))
|
|
251
|
+
if kind == 'command_execution':
|
|
252
|
+
ev = _event('command', command=_unwrap(item.get('command')), output=item.get('aggregated_output'), exit=item.get('exit_code'))
|
|
253
|
+
if item.get('status') == 'failed' or (isinstance(item.get('exit_code'), int) and item['exit_code'] != 0):
|
|
254
|
+
ev['error'] = True
|
|
255
|
+
return ev
|
|
256
|
+
if kind == 'file_change':
|
|
257
|
+
changes = item.get('changes') or []
|
|
258
|
+
return _event('edit', files=[c.get('path') for c in changes if isinstance(c, dict) and c.get('path')] or None,
|
|
259
|
+
change=[c.get('kind') for c in changes if isinstance(c, dict)] or None, error=item.get('status') == 'failed' or None)
|
|
260
|
+
if kind == 'mcp_tool_call':
|
|
261
|
+
ev = _event('tool', tool=f"{item.get('server') or 'mcp'}.{item.get('tool') or 'tool'}", input=item.get('arguments'),
|
|
262
|
+
output=item.get('result') if item.get('result') is not None else item.get('error'))
|
|
263
|
+
if item.get('error') or item.get('status') == 'failed':
|
|
264
|
+
ev['error'] = True
|
|
265
|
+
return ev
|
|
266
|
+
if kind == 'web_search':
|
|
267
|
+
return _event('tool', tool='web_search', input=item.get('query'))
|
|
268
|
+
if kind == 'todo_list':
|
|
269
|
+
return _event('tool', tool='todo', input=item.get('items'))
|
|
270
|
+
if kind == 'error':
|
|
271
|
+
return _event('error', text=item.get('message'))
|
|
272
|
+
if kind:
|
|
273
|
+
return _event('tool', tool=kind, input={k: v for k, v in item.items() if k not in ('id', 'type')} or None)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def parse_claude(text, session_file=False):
|
|
278
|
+
"""Claude's message shape: grok's and claude's streams, and Claude's session files (one block per line there)."""
|
|
279
|
+
events, by_id, tokens, meta, pending, counted = [], {}, {}, {}, [], set()
|
|
280
|
+
for value, raw in _lines(text):
|
|
281
|
+
if value is None:
|
|
282
|
+
if not session_file:
|
|
283
|
+
pending.append(raw)
|
|
284
|
+
continue
|
|
285
|
+
_stderr(events, pending)
|
|
286
|
+
kind, t = value.get('type'), _when(value.get('timestamp'))
|
|
287
|
+
if value.get('isSidechain') or value.get('isMeta'):
|
|
288
|
+
continue
|
|
289
|
+
if kind == 'system':
|
|
290
|
+
if value.get('subtype') == 'init':
|
|
291
|
+
meta.update(session=value.get('session_id'), model=value.get('model'))
|
|
292
|
+
continue
|
|
293
|
+
message = value.get('message') if isinstance(value.get('message'), dict) else None
|
|
294
|
+
if kind == 'assistant' and message:
|
|
295
|
+
mid = message.get('id')
|
|
296
|
+
if mid is None or mid not in counted: # a message's usage is repeated on each of its blocks' lines
|
|
297
|
+
counted.add(mid)
|
|
298
|
+
_tokens(tokens, message.get('usage'))
|
|
299
|
+
for block in _blocks(message.get('content')):
|
|
300
|
+
bt = block.get('type')
|
|
301
|
+
if bt == 'text' and block.get('text', '').strip():
|
|
302
|
+
events.append(_event('message', text=block['text'], t=t))
|
|
303
|
+
elif bt in ('thinking', 'redacted_thinking') and block.get('thinking'):
|
|
304
|
+
events.append(_event('reasoning', text=block['thinking'], t=t))
|
|
305
|
+
elif bt in ('tool_use', 'server_tool_use'):
|
|
306
|
+
ev = _tool_event(block.get('name'), block.get('input'), block.get('id'), t)
|
|
307
|
+
if block.get('id'):
|
|
308
|
+
by_id[block['id']] = ev
|
|
309
|
+
ev['running'] = True # until its result comes
|
|
310
|
+
events.append(ev)
|
|
311
|
+
elif bt.endswith('_tool_result') and block.get('tool_use_id') in by_id: # a server tool's result
|
|
312
|
+
by_id[block['tool_use_id']].pop('running', None)
|
|
313
|
+
_put(by_id[block['tool_use_id']], 'output', _payload(block.get('content'))[0], OUTPUT_CAP)
|
|
314
|
+
elif kind == 'user' and message:
|
|
315
|
+
for block in _blocks(message.get('content')):
|
|
316
|
+
bt = block.get('type')
|
|
317
|
+
if bt == 'tool_result':
|
|
318
|
+
ev = by_id.get(block.get('tool_use_id'))
|
|
319
|
+
out, code = _payload(block.get('content'))
|
|
320
|
+
if ev is None:
|
|
321
|
+
events.append(_event('tool', tool='result', output=out, t=t))
|
|
322
|
+
continue
|
|
323
|
+
ev.pop('running', None)
|
|
324
|
+
_put(ev, 'output', out, OUTPUT_CAP)
|
|
325
|
+
if code is not None and ev['kind'] == 'command':
|
|
326
|
+
ev['exit'] = code
|
|
327
|
+
ev['error'] = bool(block.get('is_error') or (ev['kind'] == 'command' and code not in (None, 0)))
|
|
328
|
+
elif bt == 'text' and not events:
|
|
329
|
+
# a session file starts with the task as the agent got it
|
|
330
|
+
events.append(_event('prompt', text=block.get('text'), t=t))
|
|
331
|
+
elif kind == 'result':
|
|
332
|
+
usage = value.get('usage')
|
|
333
|
+
if isinstance(usage, dict):
|
|
334
|
+
tokens.clear() # the result holds the run's totals
|
|
335
|
+
_tokens(tokens, usage)
|
|
336
|
+
meta['session'] = meta.get('session') or value.get('session_id')
|
|
337
|
+
if value.get('total_cost_usd') is not None:
|
|
338
|
+
meta['cost'] = value.get('total_cost_usd')
|
|
339
|
+
res = value.get('result')
|
|
340
|
+
error = value.get('is_error') or value.get('subtype') not in (None, 'success')
|
|
341
|
+
if error and not res:
|
|
342
|
+
res = '; '.join(str(e) for e in value.get('errors') or []) or value.get('subtype') or 'error'
|
|
343
|
+
_close(events, _event('result', text=res, error=True if error else None, tokens=dict(tokens) or None))
|
|
344
|
+
_stderr(events, pending)
|
|
345
|
+
return events, tokens, meta
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _close(events, result):
|
|
349
|
+
"""Adds the final result; its text is left out when the agent's last message already said it."""
|
|
350
|
+
last = next((ev for ev in reversed(events) if ev['kind'] == 'message'), None)
|
|
351
|
+
if last is not None and result.get('text') and last.get('text', '').strip() == result['text'].strip():
|
|
352
|
+
result.pop('text')
|
|
353
|
+
result['same'] = True
|
|
354
|
+
events.append(result)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _blocks(content):
|
|
358
|
+
if isinstance(content, str):
|
|
359
|
+
return [{'type': 'text', 'text': content}]
|
|
360
|
+
return [b for b in content or [] if isinstance(b, dict)]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _payload(content):
|
|
364
|
+
"""(text, exit code or None) of a tool result's content: a string, Claude's list of text blocks, or grok's
|
|
365
|
+
JSON string ({type, output: [bytes], exit_code, ...}, or {type, FileContent: {content, ...}})."""
|
|
366
|
+
if isinstance(content, list):
|
|
367
|
+
content = '\n'.join(b.get('text', '') if isinstance(b, dict) else str(b) for b in content)
|
|
368
|
+
if not (isinstance(content, str) and content[:1] == '{'):
|
|
369
|
+
return content, None
|
|
370
|
+
try:
|
|
371
|
+
data = json.loads(content)
|
|
372
|
+
except ValueError:
|
|
373
|
+
return content, None
|
|
374
|
+
if not isinstance(data, dict):
|
|
375
|
+
return content, None
|
|
376
|
+
inner = [v for k, v in data.items() if k != 'type']
|
|
377
|
+
if len(inner) == 1 and isinstance(inner[0], dict):
|
|
378
|
+
data = inner[0]
|
|
379
|
+
code = data.get('exit_code') if isinstance(data.get('exit_code'), int) else None
|
|
380
|
+
parts = []
|
|
381
|
+
for key in ('output', 'stdout', 'content', 'tool_output_for_prompt', 'message', 'result', 'text', 'summary', 'stderr'):
|
|
382
|
+
value = data.get(key)
|
|
383
|
+
if isinstance(value, list) and all(isinstance(x, int) for x in value):
|
|
384
|
+
value = bytes(x & 0xff for x in value).decode('utf-8', 'replace')
|
|
385
|
+
if isinstance(value, str) and value.strip():
|
|
386
|
+
parts.append(value)
|
|
387
|
+
if key not in ('output', 'stdout'): # a command's output and its stderr; else the first text
|
|
388
|
+
break
|
|
389
|
+
return ('\n'.join(parts) if parts else json.dumps(data, ensure_ascii=False, indent=1)), code
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def parse_opencode(text):
|
|
393
|
+
events, tokens, meta, pending = [], {}, {}, []
|
|
394
|
+
for value, raw in _lines(text):
|
|
395
|
+
if value is None:
|
|
396
|
+
pending.append(raw)
|
|
397
|
+
continue
|
|
398
|
+
_stderr(events, pending)
|
|
399
|
+
kind, part, t = value.get('type'), value.get('part') if isinstance(value.get('part'), dict) else {}, _when(value.get('timestamp'))
|
|
400
|
+
meta['session'] = meta.get('session') or value.get('sessionID')
|
|
401
|
+
if kind == 'text':
|
|
402
|
+
events.append(_event('message', text=part.get('text'), t=t))
|
|
403
|
+
elif kind == 'reasoning':
|
|
404
|
+
events.append(_event('reasoning', text=part.get('text'), t=t))
|
|
405
|
+
elif kind == 'tool_use':
|
|
406
|
+
state = part.get('state') if isinstance(part.get('state'), dict) else {}
|
|
407
|
+
ev = _tool_event(part.get('tool'), state.get('input'), part.get('callID'), t)
|
|
408
|
+
_put(ev, 'output', state.get('output') if state.get('status') != 'error' else state.get('error'), OUTPUT_CAP)
|
|
409
|
+
meta_ = state.get('metadata') if isinstance(state.get('metadata'), dict) else {}
|
|
410
|
+
if ev['kind'] == 'command' and isinstance(meta_.get('exit'), int):
|
|
411
|
+
ev['exit'] = meta_['exit']
|
|
412
|
+
if state.get('status') in ('completed', 'error'):
|
|
413
|
+
ev['error'] = state.get('status') == 'error' or ev.get('exit') not in (None, 0)
|
|
414
|
+
events.append(ev)
|
|
415
|
+
elif kind == 'step_finish':
|
|
416
|
+
_tokens(tokens, part.get('tokens'))
|
|
417
|
+
if part.get('cost') is not None:
|
|
418
|
+
meta['cost'] = (meta.get('cost') or 0) + part['cost']
|
|
419
|
+
elif kind == 'error':
|
|
420
|
+
err = value.get('error') if isinstance(value.get('error'), dict) else {}
|
|
421
|
+
data = err.get('data') if isinstance(err.get('data'), dict) else {}
|
|
422
|
+
events.append(_event('error', text=data.get('message') or err.get('name') or 'error', t=t))
|
|
423
|
+
_stderr(events, pending)
|
|
424
|
+
return events, tokens, meta
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def parse_json(text):
|
|
428
|
+
"""JSON lines of a shape not known here (agy's stream, say): each shown as it is, text kept readable."""
|
|
429
|
+
events = []
|
|
430
|
+
for value, raw in _lines(text):
|
|
431
|
+
if value is None:
|
|
432
|
+
events.append(_event('text', text=_ANSI.sub('', raw)))
|
|
433
|
+
continue
|
|
434
|
+
kind = str(value.get('type') or value.get('event') or 'event')
|
|
435
|
+
res = value.get('result')
|
|
436
|
+
if kind == 'result' and isinstance(res, dict): # agy --output-format stream-json's last event
|
|
437
|
+
usage = res.get('usage') if isinstance(res.get('usage'), dict) else {}
|
|
438
|
+
error = res.get('status') == 'ERROR' or bool(res.get('error'))
|
|
439
|
+
events.append(_event('result', text=res.get('response') or res.get('error'), error=True if error else None,
|
|
440
|
+
tokens={'input': usage.get('input_tokens') or 0, 'cached': usage.get('cache_read_tokens') or 0,
|
|
441
|
+
'output': (usage.get('output_tokens') or 0) + (usage.get('thinking_tokens') or 0)} if usage else None))
|
|
442
|
+
continue
|
|
443
|
+
body = value.get('text') or value.get('content') or value.get('message') or value.get('result')
|
|
444
|
+
if isinstance(body, str) and body.strip():
|
|
445
|
+
events.append(_event('error' if 'error' in kind else 'message', text=body, source=kind))
|
|
446
|
+
else:
|
|
447
|
+
events.append(_event('tool', tool=kind, input=value))
|
|
448
|
+
return _merge_text(events), {}, {}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _merge_text(events):
|
|
452
|
+
out = []
|
|
453
|
+
for ev in events:
|
|
454
|
+
if ev['kind'] == 'text' and out and out[-1]['kind'] == 'text':
|
|
455
|
+
joined, cut = cap(out[-1]['text'] + '\n' + ev['text'], TEXT_CAP)
|
|
456
|
+
out[-1]['text'] = joined
|
|
457
|
+
if cut:
|
|
458
|
+
out[-1]['text_cut'] = out[-1].get('text_cut', 0) + cut
|
|
459
|
+
else:
|
|
460
|
+
out.append(ev)
|
|
461
|
+
return out
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def parse_text(text):
|
|
465
|
+
return ([_event('text', text=_ANSI.sub('', text).strip())] if text.strip() else []), {}, {}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
PARSERS = {'codex': parse_codex, 'claude': parse_claude, 'opencode': parse_opencode, 'json': parse_json, 'text': parse_text}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------- a Claude session file ----------
|
|
472
|
+
|
|
473
|
+
def session_file(session_id, home=None, environ=None):
|
|
474
|
+
"""The session file claude left for `session_id`, or None. Only `<config dir>/projects/<one dir>/<session
|
|
475
|
+
id>.jsonl` is looked at, the config dir being $CLAUDE_CONFIG_DIR, then ~/.claude."""
|
|
476
|
+
if not session_id or not _SESSION_ID.fullmatch(str(session_id)):
|
|
477
|
+
return None
|
|
478
|
+
env = os.environ if environ is None else environ
|
|
479
|
+
home = Path(home or Path.home())
|
|
480
|
+
roots = ([Path(env['CLAUDE_CONFIG_DIR'])] if env.get('CLAUDE_CONFIG_DIR') else []) + [home / '.claude']
|
|
481
|
+
for root in roots:
|
|
482
|
+
projects = root / 'projects'
|
|
483
|
+
try:
|
|
484
|
+
dirs = [d for d in os.scandir(projects) if d.is_dir(follow_symlinks=False)]
|
|
485
|
+
except OSError:
|
|
486
|
+
continue
|
|
487
|
+
for d in dirs:
|
|
488
|
+
path = Path(d.path) / f'{session_id}.jsonl'
|
|
489
|
+
try:
|
|
490
|
+
if stat.S_ISREG(path.stat().st_mode):
|
|
491
|
+
return path
|
|
492
|
+
except OSError:
|
|
493
|
+
continue
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ---------- one execution's transcript ----------
|
|
498
|
+
|
|
499
|
+
def _read(path):
|
|
500
|
+
"""The log's text (its end when it is over LOG_CAP) and whether it was cut."""
|
|
501
|
+
try:
|
|
502
|
+
size = path.stat().st_size
|
|
503
|
+
with open(path, 'rb') as fh:
|
|
504
|
+
if size > LOG_CAP:
|
|
505
|
+
fh.seek(size - LOG_CAP)
|
|
506
|
+
data = fh.read()
|
|
507
|
+
data = data[data.find(b'\n') + 1:]
|
|
508
|
+
else:
|
|
509
|
+
data = fh.read()
|
|
510
|
+
except OSError:
|
|
511
|
+
return None, False
|
|
512
|
+
return data.decode('utf-8', 'replace'), size > LOG_CAP
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def transcript(log, harness=None, running=False, home=None, environ=None):
|
|
516
|
+
"""The transcript of the execution whose output is the file `log` (a Path, or None when there is none).
|
|
517
|
+
|
|
518
|
+
{format, source: log|session|none, session, model, events, tokens, cost, truncated, warnings}
|
|
519
|
+
"""
|
|
520
|
+
out = dict(format=None, source='none', session=None, model=None, events=[], tokens=None, cost=None, truncated=False, warnings=[])
|
|
521
|
+
if log is None:
|
|
522
|
+
out['warnings'].append('this execution has no log')
|
|
523
|
+
return out
|
|
524
|
+
try:
|
|
525
|
+
st = log.stat()
|
|
526
|
+
except OSError:
|
|
527
|
+
st = None
|
|
528
|
+
if st is None or not stat.S_ISREG(st.st_mode):
|
|
529
|
+
out['warnings'].append(f'{log.name} is not readable')
|
|
530
|
+
return out
|
|
531
|
+
key = (str(log), st.st_size, st.st_mtime_ns, harness, running)
|
|
532
|
+
if key in _CACHE:
|
|
533
|
+
answer, sid, seen = _CACHE[key]
|
|
534
|
+
if sid is None or _signature(session_file(sid, home, environ)) == seen:
|
|
535
|
+
return json.loads(answer)
|
|
536
|
+
text, cut = _read(log)
|
|
537
|
+
if text is None:
|
|
538
|
+
out['warnings'].append(f'{log.name} is not readable')
|
|
539
|
+
return out
|
|
540
|
+
if running:
|
|
541
|
+
# a line still being written is not an event yet
|
|
542
|
+
end = text.rfind('\n')
|
|
543
|
+
text = text[:end + 1] if end >= 0 else ''
|
|
544
|
+
fmt = detect(text)
|
|
545
|
+
out.update(format=fmt, source='log', truncated=cut)
|
|
546
|
+
if cut:
|
|
547
|
+
out['warnings'].append(f'{log.name} is over {LOG_CAP >> 20} MB: only its end is shown')
|
|
548
|
+
if fmt == 'claude-result':
|
|
549
|
+
result = next((v for v, _ in _lines(text) if v is not None), None) or _whole(text)
|
|
550
|
+
sid = result.get('session_id')
|
|
551
|
+
path = session_file(sid, home, environ)
|
|
552
|
+
tail = parse_claude(json.dumps(result))[0]
|
|
553
|
+
events, tokens, meta = tail, {}, {}
|
|
554
|
+
if path is not None:
|
|
555
|
+
stext, scut = _read(path)
|
|
556
|
+
if stext is not None:
|
|
557
|
+
events, tokens, meta = parse_claude(stext, session_file=True)
|
|
558
|
+
_close(events, tail[-1]) # the run's own result closes it
|
|
559
|
+
out.update(format='claude', source='session', truncated=cut or scut)
|
|
560
|
+
if out['source'] != 'session':
|
|
561
|
+
out['warnings'].append(f"{harness or 'claude'} logged only its final answer, and its session file "
|
|
562
|
+
f"({sid or 'no session id'}) is not on this machine any more")
|
|
563
|
+
if isinstance(result.get('usage'), dict):
|
|
564
|
+
tokens = {}
|
|
565
|
+
_tokens(tokens, result['usage'])
|
|
566
|
+
meta.setdefault('session', sid)
|
|
567
|
+
if result.get('total_cost_usd') is not None:
|
|
568
|
+
meta['cost'] = result['total_cost_usd']
|
|
569
|
+
else:
|
|
570
|
+
events, tokens, meta = PARSERS[fmt](text)
|
|
571
|
+
if not running:
|
|
572
|
+
for ev in events: # a call cut off when the agent stopped
|
|
573
|
+
ev.pop('running', None)
|
|
574
|
+
events, left_out = _fit(events)
|
|
575
|
+
if left_out:
|
|
576
|
+
out['warnings'].append(f'{left_out} events in the middle are left out')
|
|
577
|
+
out['truncated'] = True
|
|
578
|
+
out.update(events=events, tokens=tokens or None, session=meta.get('session'), model=meta.get('model'), cost=meta.get('cost'))
|
|
579
|
+
if len(_CACHE) > 16: # answers can be megabytes each
|
|
580
|
+
_CACHE.clear()
|
|
581
|
+
sid = result.get('session_id') if fmt == 'claude-result' else None
|
|
582
|
+
_CACHE[key] = (json.dumps(out), sid, _signature(path) if sid else None)
|
|
583
|
+
return out
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _signature(path):
|
|
587
|
+
try:
|
|
588
|
+
st = path.stat() if path is not None else None
|
|
589
|
+
except OSError:
|
|
590
|
+
return None
|
|
591
|
+
return st and (str(path), st.st_size, st.st_mtime_ns)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _fit(events):
|
|
595
|
+
"""(events, how many were left out): within EVENTS_CAP and CHARS_CAP, keeping the start and more of the end."""
|
|
596
|
+
sizes = [sum(len(v) for v in ev.values() if isinstance(v, str)) for ev in events]
|
|
597
|
+
if len(events) <= EVENTS_CAP and sum(sizes) <= CHARS_CAP:
|
|
598
|
+
return events, 0
|
|
599
|
+
head = used = 0
|
|
600
|
+
while head < len(events) and head < EVENTS_CAP // 5 and used + sizes[head] <= CHARS_CAP // 5:
|
|
601
|
+
used += sizes[head]
|
|
602
|
+
head += 1
|
|
603
|
+
tail = 0
|
|
604
|
+
while tail < len(events) - head and tail < EVENTS_CAP - EVENTS_CAP // 5 and used + sizes[-1 - tail] <= CHARS_CAP:
|
|
605
|
+
used += sizes[-1 - tail]
|
|
606
|
+
tail += 1
|
|
607
|
+
gap = len(events) - head - tail
|
|
608
|
+
return events[:head] + [_event('gap', count=gap)] + events[len(events) - tail:], gap
|