agent-session-bridge 0.2.0__py3-none-any.whl
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.
- agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
- agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
- agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
- agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
- agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
- session_bridge/__init__.py +0 -0
- session_bridge/_ids.py +43 -0
- session_bridge/cli.py +473 -0
- session_bridge/convert.py +130 -0
- session_bridge/handshake.py +175 -0
- session_bridge/ir.py +246 -0
- session_bridge/place.py +98 -0
- session_bridge/readers/__init__.py +0 -0
- session_bridge/readers/_content.py +42 -0
- session_bridge/readers/_jsonl.py +50 -0
- session_bridge/readers/_pending.py +63 -0
- session_bridge/readers/claude_code.py +226 -0
- session_bridge/readers/codex.py +203 -0
- session_bridge/readers/hermes.py +167 -0
- session_bridge/skill_install.py +134 -0
- session_bridge/skills/session-handoff/SKILL.md +119 -0
- session_bridge/tui/__init__.py +0 -0
- session_bridge/tui/actions.py +123 -0
- session_bridge/tui/app.py +65 -0
- session_bridge/tui/discovery.py +208 -0
- session_bridge/tui/options.py +86 -0
- session_bridge/tui/register.py +475 -0
- session_bridge/tui/screens.py +710 -0
- session_bridge/tui/summary.py +41 -0
- session_bridge/writers/__init__.py +0 -0
- session_bridge/writers/_common.py +288 -0
- session_bridge/writers/claude_code.py +152 -0
- session_bridge/writers/codex.py +154 -0
- session_bridge/writers/codex_db.py +302 -0
- session_bridge/writers/hermes.py +142 -0
- session_bridge/writers/hermes_db.py +294 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Reader: Claude Code session JSONL -> IR.
|
|
2
|
+
|
|
3
|
+
Claude Code interleaves control events with message entries:
|
|
4
|
+
- ``queue-operation`` {operation:enqueue|dequeue, content, sessionId}:
|
|
5
|
+
user input queued while a turn is running. An enqueue with no matching
|
|
6
|
+
dequeue is an undelivered (pending) user message.
|
|
7
|
+
- ``user`` / ``assistant`` {parentUuid, uuid, message:{...}, cwd, gitBranch,
|
|
8
|
+
version, permissionMode, sessionId}: the actual turns. ``message`` is the
|
|
9
|
+
Anthropic API message; ``content`` is a str (user) or a list of typed blocks
|
|
10
|
+
(``text`` / ``thinking`` / ``tool_use`` / ``tool_result``). Note tool_result
|
|
11
|
+
blocks arrive inside ``user`` records.
|
|
12
|
+
- ``ai-title`` / ``last-prompt`` / ``attachment``: metadata, not turns.
|
|
13
|
+
|
|
14
|
+
Thread order is an explicit parentUuid linked list; we preserve both uid and
|
|
15
|
+
parent_uid on each IR message and emit them in file order (which matches the
|
|
16
|
+
chain in practice).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
from ..ir import (
|
|
26
|
+
PLACEHOLDER_MODELS,
|
|
27
|
+
ContentBlock,
|
|
28
|
+
Message,
|
|
29
|
+
PendingState,
|
|
30
|
+
Role,
|
|
31
|
+
Session,
|
|
32
|
+
SessionMeta,
|
|
33
|
+
)
|
|
34
|
+
from ._jsonl import load_records
|
|
35
|
+
from ._pending import open_tool_calls
|
|
36
|
+
|
|
37
|
+
_MESSAGE_TYPES = {"user", "assistant"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _blocks_from_content(content: Any) -> tuple[ContentBlock, ...]:
|
|
41
|
+
"""Normalize an Anthropic message ``content`` (str or block list) to IR blocks."""
|
|
42
|
+
if isinstance(content, str):
|
|
43
|
+
return (ContentBlock.text_block(content),) if content else ()
|
|
44
|
+
if not isinstance(content, list):
|
|
45
|
+
return ()
|
|
46
|
+
|
|
47
|
+
blocks: list[ContentBlock] = []
|
|
48
|
+
for b in content:
|
|
49
|
+
if not isinstance(b, dict):
|
|
50
|
+
continue
|
|
51
|
+
bt = b.get("type")
|
|
52
|
+
if bt == "text":
|
|
53
|
+
blocks.append(ContentBlock.text_block(b.get("text", "")))
|
|
54
|
+
elif bt in ("thinking", "redacted_thinking"):
|
|
55
|
+
blocks.append(ContentBlock.reasoning(b.get("thinking", "")))
|
|
56
|
+
elif bt == "tool_use":
|
|
57
|
+
inp = b.get("input")
|
|
58
|
+
blocks.append(
|
|
59
|
+
ContentBlock.tool_call(
|
|
60
|
+
call_id=b.get("id", ""),
|
|
61
|
+
tool_name=b.get("name", ""),
|
|
62
|
+
tool_input=inp if isinstance(inp, dict) else {"_value": inp},
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
elif bt == "tool_result":
|
|
66
|
+
result = b.get("content", "")
|
|
67
|
+
nontext_parts: list[dict[str, Any]] = []
|
|
68
|
+
if isinstance(result, list):
|
|
69
|
+
# Anthropic tool_result content can itself be a block list mixing
|
|
70
|
+
# text with non-text parts (e.g. an image from a Read of a PNG).
|
|
71
|
+
# Join the text parts for the result payload, and keep each
|
|
72
|
+
# non-text part as a RAW passthrough sibling so it is preserved
|
|
73
|
+
# (same-harness) and reported (cross-harness) rather than silently
|
|
74
|
+
# dropped into an empty result.
|
|
75
|
+
text_bits = []
|
|
76
|
+
for part in result:
|
|
77
|
+
if isinstance(part, dict) and part.get("type") in (None, "text"):
|
|
78
|
+
text_bits.append(part.get("text", ""))
|
|
79
|
+
elif isinstance(part, dict) and part.get("type"):
|
|
80
|
+
nontext_parts.append(part)
|
|
81
|
+
else:
|
|
82
|
+
text_bits.append(str(part))
|
|
83
|
+
result = "\n".join(text_bits)
|
|
84
|
+
blocks.append(
|
|
85
|
+
ContentBlock.tool_result(
|
|
86
|
+
call_id=b.get("tool_use_id", ""),
|
|
87
|
+
text=result if isinstance(result, str) else json.dumps(result),
|
|
88
|
+
is_error=bool(b.get("is_error", False)),
|
|
89
|
+
# Carry non-text parts ON the result (not as sibling blocks),
|
|
90
|
+
# so writers never mistake them for a separate turn.
|
|
91
|
+
result_parts=tuple(nontext_parts),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
elif bt:
|
|
95
|
+
# Unknown block type (e.g. image, document, server_tool_use). Keep the
|
|
96
|
+
# original block verbatim as a RAW passthrough so a same-harness writer
|
|
97
|
+
# re-emits it losslessly; cross-harness writers degrade it to a
|
|
98
|
+
# reported placeholder rather than dropping it silently.
|
|
99
|
+
blocks.append(ContentBlock.raw(b, bt))
|
|
100
|
+
return tuple(blocks)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _real_model(value: Any) -> Optional[str]:
|
|
104
|
+
"""Return the model id only if it is a real (routable) one, else None.
|
|
105
|
+
|
|
106
|
+
Filters harness placeholders (e.g. Claude Code's "<synthetic>") so they don't
|
|
107
|
+
become the session model."""
|
|
108
|
+
if isinstance(value, str) and value and value not in PLACEHOLDER_MODELS:
|
|
109
|
+
return value
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_meta(record: dict[str, Any], base: SessionMeta) -> SessionMeta:
|
|
114
|
+
"""Fill session meta from the first message record that carries it."""
|
|
115
|
+
msg = record.get("message", {})
|
|
116
|
+
rec_model = _real_model(msg.get("model")) if isinstance(msg, dict) else None
|
|
117
|
+
return SessionMeta(
|
|
118
|
+
source_harness="claude-code",
|
|
119
|
+
session_id=base.session_id or record.get("sessionId"),
|
|
120
|
+
cwd=base.cwd or record.get("cwd"),
|
|
121
|
+
# Prefer the first REAL model; ignore synthetic placeholders even if they
|
|
122
|
+
# appear first in file order.
|
|
123
|
+
model=base.model or rec_model,
|
|
124
|
+
model_provider="anthropic",
|
|
125
|
+
permission_mode=base.permission_mode or record.get("permissionMode"),
|
|
126
|
+
version=base.version or record.get("version"),
|
|
127
|
+
# Keep the first branch seen; don't let a later record without gitBranch
|
|
128
|
+
# clobber it (first-non-null-wins, matching the other meta fields).
|
|
129
|
+
extra=(
|
|
130
|
+
base.extra
|
|
131
|
+
if base.extra.get("gitBranch")
|
|
132
|
+
else ({"gitBranch": record.get("gitBranch")} if record.get("gitBranch") else base.extra)
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _queued_messages(records: list[dict[str, Any]]) -> tuple[str, ...]:
|
|
138
|
+
"""Enqueued user inputs with no matching later dequeue are undelivered.
|
|
139
|
+
|
|
140
|
+
Matching is scoped per ``sessionId`` so a dequeue from one session cannot
|
|
141
|
+
consume another session's queued item when a file mixes sessions. Emission
|
|
142
|
+
order follows first-enqueue order across sessions.
|
|
143
|
+
"""
|
|
144
|
+
# Each queued item carries an enqueue sequence number so the still-pending
|
|
145
|
+
# items can be emitted in global first-enqueue order. Duplicates are kept
|
|
146
|
+
# (two identical undelivered messages are two pending items, not one) — a
|
|
147
|
+
# set-based dedup here would undercount genuinely-pending input.
|
|
148
|
+
per_session: dict[str, list[tuple[int, str]]] = {}
|
|
149
|
+
seq = 0
|
|
150
|
+
for rec in records:
|
|
151
|
+
if rec.get("type") != "queue-operation":
|
|
152
|
+
continue
|
|
153
|
+
sid = rec.get("sessionId", "")
|
|
154
|
+
op = rec.get("operation")
|
|
155
|
+
content = rec.get("content", "")
|
|
156
|
+
queue = per_session.setdefault(sid, [])
|
|
157
|
+
if op == "enqueue" and content:
|
|
158
|
+
queue.append((seq, content))
|
|
159
|
+
seq += 1
|
|
160
|
+
elif op == "dequeue" and queue:
|
|
161
|
+
# A dequeue (content-less) consumes this session's oldest queued item.
|
|
162
|
+
queue.pop(0)
|
|
163
|
+
elif op == "remove" and queue:
|
|
164
|
+
# Claude Code auto-withdraws an undelivered queued item (e.g. a
|
|
165
|
+
# background-task notification). Real `remove` records are mixed:
|
|
166
|
+
# some carry the withdrawn content, some carry none. When content is
|
|
167
|
+
# present, honor an exact match (newest-first). When absent, a remove
|
|
168
|
+
# in real traces immediately follows its own enqueue, so it withdraws
|
|
169
|
+
# the MOST-RECENTLY-enqueued pending item (LIFO).
|
|
170
|
+
if content:
|
|
171
|
+
for i in range(len(queue) - 1, -1, -1):
|
|
172
|
+
if queue[i][1] == content:
|
|
173
|
+
queue.pop(i)
|
|
174
|
+
break
|
|
175
|
+
else:
|
|
176
|
+
queue.pop()
|
|
177
|
+
else:
|
|
178
|
+
queue.pop()
|
|
179
|
+
elif op == "popAll":
|
|
180
|
+
# Claude Code flushes the ENTIRE pending queue in one op (e.g. all
|
|
181
|
+
# queued input delivered/cleared at once). Ignoring it would leave
|
|
182
|
+
# stale items that then mis-consume later dequeues; clear the queue.
|
|
183
|
+
queue.clear()
|
|
184
|
+
remaining = [item for q in per_session.values() for item in q]
|
|
185
|
+
remaining.sort(key=lambda item: item[0])
|
|
186
|
+
return tuple(content for _, content in remaining)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_claude_code(path: str | Path) -> Session:
|
|
190
|
+
path = Path(path)
|
|
191
|
+
records = load_records(path)
|
|
192
|
+
|
|
193
|
+
meta = SessionMeta(source_harness="claude-code")
|
|
194
|
+
messages: list[Message] = []
|
|
195
|
+
|
|
196
|
+
for rec in records:
|
|
197
|
+
rtype = rec.get("type")
|
|
198
|
+
if rtype not in _MESSAGE_TYPES:
|
|
199
|
+
continue
|
|
200
|
+
# Skip Claude Code's own API-error notices (auth failure, rate limit,
|
|
201
|
+
# connection dropped). They are stamped type:assistant but are harness
|
|
202
|
+
# status messages, not model output — ingesting their text ("API Error:
|
|
203
|
+
# ...") would fabricate an assistant turn in the converted transcript.
|
|
204
|
+
if rec.get("isApiErrorMessage"):
|
|
205
|
+
continue
|
|
206
|
+
meta = _extract_meta(rec, meta)
|
|
207
|
+
msg = rec.get("message", {})
|
|
208
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
209
|
+
role = Role.USER if rtype == "user" else Role.ASSISTANT
|
|
210
|
+
messages.append(
|
|
211
|
+
Message(
|
|
212
|
+
role=role,
|
|
213
|
+
content=_blocks_from_content(content),
|
|
214
|
+
uid=rec.get("uuid"),
|
|
215
|
+
parent_uid=rec.get("parentUuid"),
|
|
216
|
+
timestamp=rec.get("timestamp"),
|
|
217
|
+
raw=rec,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
msgs = tuple(messages)
|
|
222
|
+
pending = PendingState(
|
|
223
|
+
open_tool_calls=open_tool_calls(msgs),
|
|
224
|
+
queued_user_messages=_queued_messages(records),
|
|
225
|
+
)
|
|
226
|
+
return Session(meta=meta, messages=msgs, tools=(), pending=pending)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Reader: Codex (rollout-*.jsonl) session -> IR.
|
|
2
|
+
|
|
3
|
+
Codex wraps everything in ``{timestamp, type, payload}``:
|
|
4
|
+
- ``session_meta``: payload has {id, cwd, model_provider, base_instructions:{text}, cli_version}.
|
|
5
|
+
- ``turn_context``: per-turn {turn_id, model, cwd, approval_policy, sandbox_policy}.
|
|
6
|
+
Model lives here, not in session_meta.
|
|
7
|
+
- ``event_msg``: UI-facing turn events (task_started/task_complete/user_message/
|
|
8
|
+
agent_message/token_count). These DUPLICATE content that also appears as
|
|
9
|
+
``response_item`` records, so we ignore event_msg for content to avoid doubling.
|
|
10
|
+
- ``response_item``: the canonical conversation, OpenAI Responses shape:
|
|
11
|
+
- {type:message, role:user|assistant, content:[{type:input_text|output_text, text}]}
|
|
12
|
+
- {type:reasoning, summary:[{type:summary_text, text}]}
|
|
13
|
+
- {type:function_call, name, arguments:<json-string>, call_id}
|
|
14
|
+
- {type:function_call_output, call_id, output}
|
|
15
|
+
|
|
16
|
+
Purely append-ordered; no parent linkage (grouped by turn_id).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..ir import (
|
|
26
|
+
ContentBlock,
|
|
27
|
+
Message,
|
|
28
|
+
PendingState,
|
|
29
|
+
Role,
|
|
30
|
+
Session,
|
|
31
|
+
SessionMeta,
|
|
32
|
+
recover_tool_error,
|
|
33
|
+
)
|
|
34
|
+
from ._content import content_blocks
|
|
35
|
+
from ._jsonl import load_records
|
|
36
|
+
from ._pending import open_tool_calls
|
|
37
|
+
|
|
38
|
+
_ROLE_FROM_CODEX = {
|
|
39
|
+
"user": Role.USER,
|
|
40
|
+
"assistant": Role.ASSISTANT,
|
|
41
|
+
"system": Role.SYSTEM,
|
|
42
|
+
"developer": Role.SYSTEM,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _reasoning_text(payload: dict[str, Any]) -> str:
|
|
47
|
+
"""Codex reasoning appears in either ``summary`` or ``content``, and each may
|
|
48
|
+
be a list of ``{type, text}`` blocks (real sessions) or a plain string.
|
|
49
|
+
Prefer non-empty ``content`` (the full reasoning) over ``summary``."""
|
|
50
|
+
|
|
51
|
+
def _join(value: Any) -> str:
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
return value
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return "\n".join(
|
|
56
|
+
b.get("text", "") for b in value if isinstance(b, dict) and b.get("text")
|
|
57
|
+
)
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
content = _join(payload.get("content"))
|
|
61
|
+
if content.strip():
|
|
62
|
+
return content
|
|
63
|
+
return _join(payload.get("summary"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_arguments(raw_args: Any) -> dict[str, Any]:
|
|
67
|
+
if isinstance(raw_args, dict):
|
|
68
|
+
return raw_args
|
|
69
|
+
if isinstance(raw_args, str) and raw_args.strip():
|
|
70
|
+
try:
|
|
71
|
+
parsed = json.loads(raw_args)
|
|
72
|
+
return parsed if isinstance(parsed, dict) else {"_value": parsed}
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
return {"_raw": raw_args}
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_codex(path: str | Path) -> Session:
|
|
79
|
+
path = Path(path)
|
|
80
|
+
records = load_records(path)
|
|
81
|
+
|
|
82
|
+
meta = SessionMeta(source_harness="codex", model_provider="openai")
|
|
83
|
+
messages: list[Message] = []
|
|
84
|
+
turn_models: set[str] = set()
|
|
85
|
+
|
|
86
|
+
for rec in records:
|
|
87
|
+
rtype = rec.get("type")
|
|
88
|
+
payload = rec.get("payload", {})
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
continue
|
|
91
|
+
ts = rec.get("timestamp")
|
|
92
|
+
|
|
93
|
+
if rtype == "session_meta":
|
|
94
|
+
base = payload.get("base_instructions")
|
|
95
|
+
instr = base.get("text") if isinstance(base, dict) else base
|
|
96
|
+
meta = SessionMeta(
|
|
97
|
+
source_harness="codex",
|
|
98
|
+
session_id=payload.get("id") or payload.get("session_id"),
|
|
99
|
+
cwd=payload.get("cwd"),
|
|
100
|
+
model_provider=payload.get("model_provider", "openai"),
|
|
101
|
+
system_instructions=instr,
|
|
102
|
+
version=payload.get("cli_version"),
|
|
103
|
+
)
|
|
104
|
+
elif rtype == "turn_context":
|
|
105
|
+
# Model and approval policy live here; keep the first seen. Also record
|
|
106
|
+
# every distinct per-turn model so a mid-session model switch (Codex's
|
|
107
|
+
# per-turn mechanism) can be reported as lossy — the IR keeps only one
|
|
108
|
+
# session model.
|
|
109
|
+
from dataclasses import replace
|
|
110
|
+
|
|
111
|
+
if payload.get("model"):
|
|
112
|
+
turn_models.add(payload["model"])
|
|
113
|
+
updates: dict[str, Any] = {}
|
|
114
|
+
if not meta.model and payload.get("model"):
|
|
115
|
+
updates["model"] = payload["model"]
|
|
116
|
+
if not meta.permission_mode and payload.get("approval_policy"):
|
|
117
|
+
updates["permission_mode"] = payload["approval_policy"]
|
|
118
|
+
if not meta.cwd and payload.get("cwd"):
|
|
119
|
+
updates["cwd"] = payload["cwd"]
|
|
120
|
+
if updates:
|
|
121
|
+
meta = replace(meta, **updates)
|
|
122
|
+
elif rtype == "response_item":
|
|
123
|
+
ptype = payload.get("type")
|
|
124
|
+
if ptype == "message":
|
|
125
|
+
role = _ROLE_FROM_CODEX.get(payload.get("role"), Role.ASSISTANT)
|
|
126
|
+
# Text parts -> TEXT blocks, non-text parts -> RAW passthrough
|
|
127
|
+
# (not silently dropped). Empty content is preserved as () so the
|
|
128
|
+
# turn/message count round-trips.
|
|
129
|
+
content = content_blocks(payload.get("content"))
|
|
130
|
+
messages.append(
|
|
131
|
+
Message(role=role, content=content, timestamp=ts, raw=rec)
|
|
132
|
+
)
|
|
133
|
+
elif ptype == "reasoning":
|
|
134
|
+
text = _reasoning_text(payload)
|
|
135
|
+
# A reasoning record means a reasoning block EXISTED, so always
|
|
136
|
+
# emit one even when its summary text is empty. Real extended
|
|
137
|
+
# thinking stores content in an (unreadable) signature with empty
|
|
138
|
+
# visible text, so dropping empty-text reasoning would lose ~all
|
|
139
|
+
# real reasoning blocks and leave a contentless assistant turn.
|
|
140
|
+
messages.append(
|
|
141
|
+
Message(role=Role.ASSISTANT,
|
|
142
|
+
content=(ContentBlock.reasoning(text),),
|
|
143
|
+
timestamp=ts, raw=rec)
|
|
144
|
+
)
|
|
145
|
+
elif ptype == "function_call":
|
|
146
|
+
messages.append(
|
|
147
|
+
Message(
|
|
148
|
+
role=Role.ASSISTANT,
|
|
149
|
+
content=(
|
|
150
|
+
ContentBlock.tool_call(
|
|
151
|
+
call_id=payload.get("call_id", ""),
|
|
152
|
+
tool_name=payload.get("name", ""),
|
|
153
|
+
tool_input=_parse_arguments(payload.get("arguments")),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
timestamp=ts,
|
|
157
|
+
raw=rec,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
elif ptype == "function_call_output":
|
|
161
|
+
out = payload.get("output", "")
|
|
162
|
+
is_error = False
|
|
163
|
+
if isinstance(out, dict):
|
|
164
|
+
# A dict output can carry an explicit failure flag; preserve it
|
|
165
|
+
# so a failed tool call is not reported as successful. Accept
|
|
166
|
+
# both the JSON bool False and a string "false".
|
|
167
|
+
success = out.get("success")
|
|
168
|
+
failed = (
|
|
169
|
+
success is False
|
|
170
|
+
or (isinstance(success, str) and success.strip().lower() == "false")
|
|
171
|
+
or bool(out.get("error"))
|
|
172
|
+
)
|
|
173
|
+
if failed:
|
|
174
|
+
is_error = True
|
|
175
|
+
out = out.get("content", json.dumps(out))
|
|
176
|
+
text = out if isinstance(out, str) else json.dumps(out)
|
|
177
|
+
# Recover an error baked into text by a prior hop (a writer with no
|
|
178
|
+
# native error flag prefixes ERROR_MARKER), so failure survives.
|
|
179
|
+
text, marked = recover_tool_error(text)
|
|
180
|
+
messages.append(
|
|
181
|
+
Message(
|
|
182
|
+
role=Role.TOOL,
|
|
183
|
+
content=(
|
|
184
|
+
ContentBlock.tool_result(
|
|
185
|
+
call_id=payload.get("call_id", ""),
|
|
186
|
+
text=text,
|
|
187
|
+
is_error=is_error or marked,
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
timestamp=ts,
|
|
191
|
+
raw=rec,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
# event_msg intentionally ignored (duplicates response_item content)
|
|
195
|
+
|
|
196
|
+
if len(turn_models) > 1:
|
|
197
|
+
from dataclasses import replace
|
|
198
|
+
|
|
199
|
+
meta = replace(meta, extra={**meta.extra, "turn_models": sorted(turn_models)})
|
|
200
|
+
|
|
201
|
+
msgs = tuple(messages)
|
|
202
|
+
pending = PendingState(open_tool_calls=open_tool_calls(msgs))
|
|
203
|
+
return Session(meta=meta, messages=msgs, tools=(), pending=pending)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Reader: Hermes session JSONL -> IR.
|
|
2
|
+
|
|
3
|
+
Hermes stores an OpenAI-chat-completion-style log:
|
|
4
|
+
- ``session_meta``: {model, platform, tools:[{type:function, function:{name,description,parameters}}], timestamp}
|
|
5
|
+
- ``user``: {content, timestamp}
|
|
6
|
+
- ``assistant``: {content, reasoning, tool_calls:[{id/call_id, function:{name, arguments:<json-string>}}], finish_reason, timestamp}
|
|
7
|
+
- ``tool``: {content, tool_call_id, timestamp} # a tool result, linked by tool_call_id
|
|
8
|
+
|
|
9
|
+
Records are append-ordered; there is no explicit parent linkage, so IR
|
|
10
|
+
``parent_uid`` is left None and order is list order.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..ir import (
|
|
20
|
+
ContentBlock,
|
|
21
|
+
Message,
|
|
22
|
+
PendingState,
|
|
23
|
+
Role,
|
|
24
|
+
Session,
|
|
25
|
+
SessionMeta,
|
|
26
|
+
ToolSchema,
|
|
27
|
+
recover_tool_error,
|
|
28
|
+
)
|
|
29
|
+
from ._content import content_blocks
|
|
30
|
+
from ._jsonl import load_records
|
|
31
|
+
from ._pending import open_tool_calls
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_tool_schemas(raw_tools: Any) -> tuple[ToolSchema, ...]:
|
|
35
|
+
schemas: list[ToolSchema] = []
|
|
36
|
+
if not isinstance(raw_tools, list):
|
|
37
|
+
return ()
|
|
38
|
+
for entry in raw_tools:
|
|
39
|
+
fn = entry.get("function", entry) if isinstance(entry, dict) else {}
|
|
40
|
+
name = fn.get("name")
|
|
41
|
+
if not name:
|
|
42
|
+
continue
|
|
43
|
+
schemas.append(
|
|
44
|
+
ToolSchema(
|
|
45
|
+
name=name,
|
|
46
|
+
description=fn.get("description"),
|
|
47
|
+
parameters=fn.get("parameters"),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return tuple(schemas)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_arguments(raw_args: Any) -> dict[str, Any]:
|
|
54
|
+
"""Hermes stores tool arguments as a JSON string (OpenAI style)."""
|
|
55
|
+
if isinstance(raw_args, dict):
|
|
56
|
+
return raw_args
|
|
57
|
+
if isinstance(raw_args, str) and raw_args.strip():
|
|
58
|
+
try:
|
|
59
|
+
parsed = json.loads(raw_args)
|
|
60
|
+
return parsed if isinstance(parsed, dict) else {"_value": parsed}
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return {"_raw": raw_args}
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _assistant_blocks(record: dict[str, Any]) -> tuple[ContentBlock, ...]:
|
|
67
|
+
blocks: list[ContentBlock] = []
|
|
68
|
+
reasoning = record.get("reasoning")
|
|
69
|
+
# Reasoning presence has two signals in real Hermes data: the flat visible
|
|
70
|
+
# `reasoning` string, and a sibling `codex_reasoning_items` list (opaque
|
|
71
|
+
# encrypted extended-thinking, mirroring Codex). A real record often has
|
|
72
|
+
# reasoning=null WITH codex_reasoning_items present — emit a reasoning block
|
|
73
|
+
# from whichever signal exists so a real reasoning turn isn't silently lost.
|
|
74
|
+
codex_items = record.get("codex_reasoning_items")
|
|
75
|
+
if isinstance(reasoning, str) and reasoning.strip():
|
|
76
|
+
blocks.append(ContentBlock.reasoning(reasoning))
|
|
77
|
+
elif isinstance(codex_items, list) and codex_items:
|
|
78
|
+
# Content is opaque/encrypted; preserve presence with empty visible text.
|
|
79
|
+
blocks.append(ContentBlock.reasoning(""))
|
|
80
|
+
# Text parts -> TEXT, non-text parts -> RAW passthrough (not dropped).
|
|
81
|
+
blocks.extend(content_blocks(record.get("content")))
|
|
82
|
+
for call in record.get("tool_calls") or []:
|
|
83
|
+
if not isinstance(call, dict):
|
|
84
|
+
continue
|
|
85
|
+
call_id = call.get("call_id") or call.get("id") or ""
|
|
86
|
+
fn = call.get("function", {})
|
|
87
|
+
blocks.append(
|
|
88
|
+
ContentBlock.tool_call(
|
|
89
|
+
call_id=call_id,
|
|
90
|
+
tool_name=fn.get("name", ""),
|
|
91
|
+
tool_input=_parse_arguments(fn.get("arguments")),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return tuple(blocks)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_hermes(path: str | Path) -> Session:
|
|
98
|
+
path = Path(path)
|
|
99
|
+
records = load_records(path)
|
|
100
|
+
|
|
101
|
+
meta = SessionMeta(source_harness="hermes")
|
|
102
|
+
tools: tuple[ToolSchema, ...] = ()
|
|
103
|
+
messages: list[Message] = []
|
|
104
|
+
|
|
105
|
+
for rec in records:
|
|
106
|
+
role = rec.get("role")
|
|
107
|
+
if role == "session_meta":
|
|
108
|
+
meta = SessionMeta(
|
|
109
|
+
source_harness="hermes",
|
|
110
|
+
model=rec.get("model"),
|
|
111
|
+
version=rec.get("platform"),
|
|
112
|
+
extra={"platform": rec.get("platform")},
|
|
113
|
+
)
|
|
114
|
+
tools = _parse_tool_schemas(rec.get("tools"))
|
|
115
|
+
elif role == "user":
|
|
116
|
+
# Text parts -> TEXT, non-text parts (image_url, ...) -> RAW passthrough.
|
|
117
|
+
blocks = content_blocks(rec.get("content"))
|
|
118
|
+
messages.append(
|
|
119
|
+
Message(
|
|
120
|
+
role=Role.USER,
|
|
121
|
+
content=blocks,
|
|
122
|
+
timestamp=rec.get("timestamp"),
|
|
123
|
+
raw=rec,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
elif role == "assistant":
|
|
127
|
+
messages.append(
|
|
128
|
+
Message(
|
|
129
|
+
role=Role.ASSISTANT,
|
|
130
|
+
content=_assistant_blocks(rec),
|
|
131
|
+
timestamp=rec.get("timestamp"),
|
|
132
|
+
raw=rec,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
elif role == "system":
|
|
136
|
+
# write_hermes emits SYSTEM messages (e.g. the injected handshake) as
|
|
137
|
+
# role:system; read them back rather than dropping the whole class.
|
|
138
|
+
blocks = content_blocks(rec.get("content"))
|
|
139
|
+
messages.append(
|
|
140
|
+
Message(
|
|
141
|
+
role=Role.SYSTEM,
|
|
142
|
+
content=blocks,
|
|
143
|
+
timestamp=rec.get("timestamp"),
|
|
144
|
+
raw=rec,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
elif role == "tool":
|
|
148
|
+
text, marked = recover_tool_error(rec.get("content", "") or "")
|
|
149
|
+
messages.append(
|
|
150
|
+
Message(
|
|
151
|
+
role=Role.TOOL,
|
|
152
|
+
content=(
|
|
153
|
+
ContentBlock.tool_result(
|
|
154
|
+
call_id=rec.get("tool_call_id", ""),
|
|
155
|
+
text=text,
|
|
156
|
+
is_error=marked,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
timestamp=rec.get("timestamp"),
|
|
160
|
+
raw=rec,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
# unknown roles are ignored but preserved via raw if needed later
|
|
164
|
+
|
|
165
|
+
msgs = tuple(messages)
|
|
166
|
+
pending = PendingState(open_tool_calls=open_tool_calls(msgs))
|
|
167
|
+
return Session(meta=meta, messages=msgs, tools=tools, pending=pending)
|