vamp-cli 0.1.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.
- vamp_cli/__init__.py +0 -0
- vamp_cli/agent/__init__.py +0 -0
- vamp_cli/agent/context_sync.py +72 -0
- vamp_cli/agent/graph.py +159 -0
- vamp_cli/agent/instructions.py +18 -0
- vamp_cli/agent/nodes.py +375 -0
- vamp_cli/agent/roles.py +8 -0
- vamp_cli/agent/runtime/__init__.py +0 -0
- vamp_cli/agent/runtime/runtime.py +133 -0
- vamp_cli/agent/state.py +55 -0
- vamp_cli/agent/status.py +17 -0
- vamp_cli/config/__init__.py +0 -0
- vamp_cli/config/loader.py +152 -0
- vamp_cli/config/schema.py +82 -0
- vamp_cli/llm/__init__.py +0 -0
- vamp_cli/llm/adapter.py +52 -0
- vamp_cli/llm/adapter_registry.py +26 -0
- vamp_cli/llm/debug_callbacks.py +18 -0
- vamp_cli/llm/router.py +247 -0
- vamp_cli/llm/usage.py +170 -0
- vamp_cli/logging_setup/logging_setup.py +37 -0
- vamp_cli/main.py +1023 -0
- vamp_cli/persistence/__init__.py +29 -0
- vamp_cli/persistence/compaction.py +372 -0
- vamp_cli/persistence/emitter.py +371 -0
- vamp_cli/persistence/event_log.py +303 -0
- vamp_cli/persistence/fork.py +81 -0
- vamp_cli/persistence/loader.py +616 -0
- vamp_cli/persistence/transcript.py +160 -0
- vamp_cli/prompts/prompts.py +182 -0
- vamp_cli/sandbox/__init__.py +0 -0
- vamp_cli/sandbox/base.py +22 -0
- vamp_cli/sandbox/config.py +16 -0
- vamp_cli/sandbox/docker.py +293 -0
- vamp_cli/sandbox/manager.py +31 -0
- vamp_cli/sandbox/result.py +14 -0
- vamp_cli/sandbox/terminal.py +151 -0
- vamp_cli/tools/__init__.py +0 -0
- vamp_cli/tools/base.py +44 -0
- vamp_cli/tools/diagnostic.py +120 -0
- vamp_cli/tools/execute_cmd.py +16 -0
- vamp_cli/tools/files.py +652 -0
- vamp_cli/tools/registry.py +163 -0
- vamp_cli/tools/tools_classes.py +622 -0
- vamp_cli/utils/__init__.py +0 -0
- vamp_cli/utils/error_codes.py +7 -0
- vamp_cli/utils/ignore.py +36 -0
- vamp_cli/utils/sha_256.py +32 -0
- vamp_cli/utils/token_tracker.py +31 -0
- vamp_cli/workspace/__init__.py +0 -0
- vamp_cli/workspace/workspace.py +16 -0
- vamp_cli-0.1.0.dist-info/METADATA +317 -0
- vamp_cli-0.1.0.dist-info/RECORD +57 -0
- vamp_cli-0.1.0.dist-info/WHEEL +5 -0
- vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- vamp_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fork a session: write a `fork` event, seed a new checkpointer thread with the reconstructed state at the fork point.
|
|
3
|
+
|
|
4
|
+
The parent branch is untouched. No file is rewritten. The fork event's parentUuid is the fork point, so walking back from any future head naturally includes only the current branch.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from langgraph.graph import START
|
|
13
|
+
|
|
14
|
+
from vamp_cli.persistence.event_log import EventLog, uuid7
|
|
15
|
+
from vamp_cli.persistence.loader import load_chain, reconstruct_agent_state
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def fork_at_event(
|
|
19
|
+
log: EventLog,
|
|
20
|
+
*,
|
|
21
|
+
session_id: str,
|
|
22
|
+
fork_point_uuid: str,
|
|
23
|
+
graph,
|
|
24
|
+
reason: str = "user_rewind",
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Rewind the head to fork_point_uuid and seed a new checkpointer thread.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
{
|
|
31
|
+
"fork_uuid": the uuid of the new fork event (new head),
|
|
32
|
+
"thread_id": the new checkpointer thread id,
|
|
33
|
+
"state": the reconstructed AgentState at the fork point,
|
|
34
|
+
"events_kept": number of events in the fork-point chain,
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
# 1. Drain pending writes so we know exactly what's on disk.
|
|
38
|
+
await log.flush()
|
|
39
|
+
|
|
40
|
+
old_head = log.head_uuid
|
|
41
|
+
new_thread_id = f"{session_id}:{uuid7()}"
|
|
42
|
+
|
|
43
|
+
# 2. Reconstruct state at the fork point BEFORE we write the fork event.
|
|
44
|
+
# (After the fork, the fork-point chain is a strict prefix of the
|
|
45
|
+
# current branch, so this is the correct snapshot.)
|
|
46
|
+
sessions_dir = log.path.parent
|
|
47
|
+
chain = load_chain(sessions_dir, session_id, leaf_uuid=fork_point_uuid)
|
|
48
|
+
|
|
49
|
+
start_ev = next((e for e in chain if e.get("type") == "session_start"), None)
|
|
50
|
+
prompts = (start_ev or {}).get("data", {}).get("system_prompts")
|
|
51
|
+
|
|
52
|
+
state = reconstruct_agent_state(chain, system_prompts=prompts)
|
|
53
|
+
|
|
54
|
+
# 3. Write the fork event. Its parentUuid is the fork point, so it
|
|
55
|
+
# becomes a sibling of whatever the old branch continued to.
|
|
56
|
+
fork_uuid = await log.append(
|
|
57
|
+
"fork",
|
|
58
|
+
{
|
|
59
|
+
"fork_point": fork_point_uuid,
|
|
60
|
+
"from_head": old_head,
|
|
61
|
+
"thread_id": new_thread_id,
|
|
62
|
+
"reason": reason,
|
|
63
|
+
},
|
|
64
|
+
parent_uuid=fork_point_uuid,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# 4. Seed a fresh checkpointer thread with the reconstructed state.
|
|
68
|
+
# as_node=START tells LangGraph to compute `next` from START's
|
|
69
|
+
# outgoing edges - i.e. re-enter the graph from the top.
|
|
70
|
+
new_config = {
|
|
71
|
+
"configurable": {"thread_id": new_thread_id},
|
|
72
|
+
"recursion_limit": 200,
|
|
73
|
+
}
|
|
74
|
+
await graph.aupdate_state(new_config, state, as_node=START)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"fork_uuid": fork_uuid,
|
|
78
|
+
"thread_id": new_thread_id,
|
|
79
|
+
"state": state,
|
|
80
|
+
"events_kept": len(chain),
|
|
81
|
+
}
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rehydrate a session from its JSONL event log + LangGraph checkpointer.
|
|
3
|
+
|
|
4
|
+
Responsibilities:
|
|
5
|
+
- Walk the append-only chain from session_start to the current leaf.
|
|
6
|
+
- Derive CLI-side display state (mode, "have we run yet").
|
|
7
|
+
- Detect a parked interrupt (request without response).
|
|
8
|
+
- Load authoritative graph state from the checkpointer.
|
|
9
|
+
- Fall back to reconstructing graph state from the log if the
|
|
10
|
+
checkpointer is missing or empty.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import concurrent.futures
|
|
20
|
+
from typing import Any
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
try:
|
|
23
|
+
import orjson as fast_json
|
|
24
|
+
except ImportError:
|
|
25
|
+
fast_json = json
|
|
26
|
+
|
|
27
|
+
from langchain_core.messages import (
|
|
28
|
+
AIMessage,
|
|
29
|
+
BaseMessage,
|
|
30
|
+
HumanMessage,
|
|
31
|
+
SystemMessage,
|
|
32
|
+
ToolMessage,
|
|
33
|
+
)
|
|
34
|
+
from langchain_core.runnables import RunnableConfig
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Chain loading
|
|
38
|
+
def load_chain(
|
|
39
|
+
sessions_dir: Path,
|
|
40
|
+
session_id: str,
|
|
41
|
+
*,
|
|
42
|
+
leaf_uuid: str | None = None,
|
|
43
|
+
) -> list[dict]:
|
|
44
|
+
"""
|
|
45
|
+
Return events in chain order (oldest first).
|
|
46
|
+
|
|
47
|
+
If leaf_uuid is None, read the .head sidecar; if that's missing, find
|
|
48
|
+
the leaf by scanning. Walk parentUuid backward from the leaf to the
|
|
49
|
+
session_start event, then reverse.
|
|
50
|
+
|
|
51
|
+
Malformed lines are skipped silently - the log is never assumed clean.
|
|
52
|
+
"""
|
|
53
|
+
path = sessions_dir / f"{session_id}.jsonl"
|
|
54
|
+
if not path.exists():
|
|
55
|
+
raise FileNotFoundError(f"session log not found: {path}")
|
|
56
|
+
|
|
57
|
+
by_uuid: dict[str, dict] = {}
|
|
58
|
+
order: list[str] = []
|
|
59
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
60
|
+
for line in f:
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
ev = json.loads(line)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
continue
|
|
68
|
+
u = ev.get("uuid")
|
|
69
|
+
if u:
|
|
70
|
+
by_uuid[u] = ev
|
|
71
|
+
order.append(u)
|
|
72
|
+
|
|
73
|
+
if not order:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
if leaf_uuid is None:
|
|
77
|
+
head_path = sessions_dir / f"{session_id}.head"
|
|
78
|
+
if head_path.exists():
|
|
79
|
+
leaf_uuid = head_path.read_text(encoding="utf-8").strip() or None
|
|
80
|
+
|
|
81
|
+
if leaf_uuid is None or leaf_uuid not in by_uuid:
|
|
82
|
+
# No sidecar / stale sidecar - pick newest leaf.
|
|
83
|
+
parents = {ev.get("parentUuid") for ev in by_uuid.values() if ev.get("parentUuid")}
|
|
84
|
+
leaves = [u for u in order if u not in parents]
|
|
85
|
+
if not leaves:
|
|
86
|
+
return []
|
|
87
|
+
leaf_uuid = max(leaves)
|
|
88
|
+
|
|
89
|
+
chain: list[dict] = []
|
|
90
|
+
cursor: str | None = leaf_uuid
|
|
91
|
+
while cursor is not None:
|
|
92
|
+
ev = by_uuid.get(cursor)
|
|
93
|
+
if ev is None:
|
|
94
|
+
break
|
|
95
|
+
chain.append(ev)
|
|
96
|
+
cursor = ev.get("parentUuid")
|
|
97
|
+
chain.reverse()
|
|
98
|
+
return chain
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Display-state derivation
|
|
103
|
+
def derive_display_state(events: list[dict]) -> dict[str, Any]:
|
|
104
|
+
"""
|
|
105
|
+
Replay events into the small amount of state the CLI loop cares about.
|
|
106
|
+
- selected_mode: last mode_switch.to, default 'normal'
|
|
107
|
+
- executed_mode: the mode of the most recent user_message
|
|
108
|
+
- is_session_started: True once any turn has been sent
|
|
109
|
+
"""
|
|
110
|
+
selected_mode = "normal"
|
|
111
|
+
executed_mode = "normal"
|
|
112
|
+
is_session_started = False
|
|
113
|
+
|
|
114
|
+
for ev in events:
|
|
115
|
+
t = ev.get("type")
|
|
116
|
+
d = ev.get("data") or {}
|
|
117
|
+
if t == "mode_switch":
|
|
118
|
+
selected_mode = d.get("to", selected_mode)
|
|
119
|
+
elif t == "user_message":
|
|
120
|
+
executed_mode = d.get("mode", executed_mode)
|
|
121
|
+
is_session_started = True
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"selected_mode": selected_mode,
|
|
125
|
+
"executed_mode": executed_mode,
|
|
126
|
+
"is_session_started": is_session_started,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def detect_pending_interrupt(events: list[dict]) -> dict | None:
|
|
131
|
+
"""
|
|
132
|
+
Return the payload of an interrupt_request that has no matching
|
|
133
|
+
interrupt_response after it. Returns None if the session is not
|
|
134
|
+
parked at an interrupt.
|
|
135
|
+
"""
|
|
136
|
+
pending: dict | None = None
|
|
137
|
+
for ev in events:
|
|
138
|
+
t = ev.get("type")
|
|
139
|
+
if t == "interrupt_request":
|
|
140
|
+
d = ev.get("data") or {}
|
|
141
|
+
pending = {
|
|
142
|
+
"kind": d.get("kind"),
|
|
143
|
+
"payload": d.get("payload") or {},
|
|
144
|
+
"event_uuid": ev.get("uuid"),
|
|
145
|
+
}
|
|
146
|
+
elif t == "interrupt_response":
|
|
147
|
+
pending = None
|
|
148
|
+
elif t in ("session_end",):
|
|
149
|
+
# A cleanly-ended session can't be parked at an interrupt.
|
|
150
|
+
pending = None
|
|
151
|
+
return pending
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Best-effort AgentState reconstruction
|
|
155
|
+
def reconstruct_agent_state(
|
|
156
|
+
events: list[dict],
|
|
157
|
+
system_prompts: dict[str, str] | None = None,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""
|
|
160
|
+
Rebuild an AgentState-shaped dict from the JSONL.
|
|
161
|
+
Only used when the SQLite checkpointer is missing or empty. Lossy:
|
|
162
|
+
- reasoning_content preserved; usage / metadata dropped.
|
|
163
|
+
- Tool-call routing by node name (falls back to 'coder_messages').
|
|
164
|
+
- _pending_* pipeline fields reset to safe defaults.
|
|
165
|
+
"""
|
|
166
|
+
coder_msgs: list[BaseMessage] = []
|
|
167
|
+
planner_msgs: list[BaseMessage] = []
|
|
168
|
+
|
|
169
|
+
if system_prompts:
|
|
170
|
+
if system_prompts.get("coder"):
|
|
171
|
+
coder_msgs.append(SystemMessage(
|
|
172
|
+
content=system_prompts["coder"], id="coder_system_prompt",
|
|
173
|
+
))
|
|
174
|
+
if system_prompts.get("planner"):
|
|
175
|
+
planner_msgs.append(SystemMessage(
|
|
176
|
+
content=system_prompts["planner"], id="planner_system_prompt",
|
|
177
|
+
))
|
|
178
|
+
|
|
179
|
+
shadowed, _ = build_shadow_map(events)
|
|
180
|
+
|
|
181
|
+
task: str | None = None
|
|
182
|
+
mode = "normal"
|
|
183
|
+
plan: str | None = None
|
|
184
|
+
todo: list[dict] = []
|
|
185
|
+
iteration = 0
|
|
186
|
+
|
|
187
|
+
def _channel_for(node: str | None) -> list[BaseMessage]:
|
|
188
|
+
return planner_msgs if node == "planner" else coder_msgs
|
|
189
|
+
|
|
190
|
+
for ev in events:
|
|
191
|
+
u = ev.get("uuid")
|
|
192
|
+
t = ev.get("type")
|
|
193
|
+
d = ev.get("data") or {}
|
|
194
|
+
|
|
195
|
+
# compaction event: inject summary, never process as a message
|
|
196
|
+
if t == "compaction":
|
|
197
|
+
summary = (d.get("summary") or "").strip()
|
|
198
|
+
if summary:
|
|
199
|
+
marker = SystemMessage(
|
|
200
|
+
content=f"[COMPACTED HISTORY - summary of earlier turns]\n{summary}",
|
|
201
|
+
id=f"compaction_{u}",
|
|
202
|
+
)
|
|
203
|
+
channel = d.get("channel", "coder_messages")
|
|
204
|
+
if channel == "planner_messages":
|
|
205
|
+
planner_msgs.append(marker)
|
|
206
|
+
elif channel == "both":
|
|
207
|
+
coder_msgs.append(marker)
|
|
208
|
+
planner_msgs.append(marker)
|
|
209
|
+
else:
|
|
210
|
+
coder_msgs.append(marker)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# shadowed event: skip entirely (already in a summary)
|
|
214
|
+
if u in shadowed:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
if t == "user_message":
|
|
218
|
+
mode = d.get("mode", mode)
|
|
219
|
+
msg = HumanMessage(content=d.get("content", ""))
|
|
220
|
+
if mode == "plan":
|
|
221
|
+
planner_msgs.append(msg)
|
|
222
|
+
else:
|
|
223
|
+
coder_msgs.append(msg)
|
|
224
|
+
task = d.get("content", task)
|
|
225
|
+
|
|
226
|
+
elif t == "assistant_message":
|
|
227
|
+
node = d.get("node")
|
|
228
|
+
tool_calls: list[dict] = []
|
|
229
|
+
for tc in d.get("tool_calls") or []:
|
|
230
|
+
tool_calls.append({
|
|
231
|
+
"id": tc.get("id") or "",
|
|
232
|
+
"name": tc.get("name") or "",
|
|
233
|
+
"args": tc.get("args") or {},
|
|
234
|
+
"type": "tool_call",
|
|
235
|
+
})
|
|
236
|
+
ai = AIMessage(
|
|
237
|
+
content=d.get("content", "") or "",
|
|
238
|
+
tool_calls=tool_calls,
|
|
239
|
+
)
|
|
240
|
+
if d.get("reasoning_content"):
|
|
241
|
+
ai.additional_kwargs = {"reasoning_content": d["reasoning_content"]}
|
|
242
|
+
_channel_for(node).append(ai)
|
|
243
|
+
iteration += 1
|
|
244
|
+
|
|
245
|
+
elif t == "tool_result":
|
|
246
|
+
node = d.get("node")
|
|
247
|
+
tm = ToolMessage(
|
|
248
|
+
content=d.get("content", "") or "",
|
|
249
|
+
tool_call_id=d.get("tool_call_id") or "",
|
|
250
|
+
name=d.get("tool_name") or "",
|
|
251
|
+
status=d.get("status") or "success",
|
|
252
|
+
)
|
|
253
|
+
_channel_for(node).append(tm)
|
|
254
|
+
|
|
255
|
+
# submit_plan produces a plan text we can recover from the
|
|
256
|
+
# tool_result payload - this is what makes plan mode work on
|
|
257
|
+
# a reconstructed session.
|
|
258
|
+
if d.get("tool_name") == "submit_plan":
|
|
259
|
+
raw = d.get("content") or ""
|
|
260
|
+
try:
|
|
261
|
+
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
|
262
|
+
if isinstance(parsed, dict) and "plan" in parsed:
|
|
263
|
+
plan = parsed["plan"]
|
|
264
|
+
except (json.JSONDecodeError, TypeError):
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
elif t == "todo_update":
|
|
268
|
+
todo = d.get("todos") or d.get("todo") or todo
|
|
269
|
+
|
|
270
|
+
elif t == "mode_switch":
|
|
271
|
+
mode = d.get("to", mode)
|
|
272
|
+
|
|
273
|
+
elif t == "plan_submitted":
|
|
274
|
+
plan = d.get("plan", plan)
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
"coder_messages": coder_msgs,
|
|
278
|
+
"planner_messages": planner_msgs,
|
|
279
|
+
"task": task,
|
|
280
|
+
"mode": mode,
|
|
281
|
+
"plan": plan,
|
|
282
|
+
"todo": todo,
|
|
283
|
+
"iteration": iteration,
|
|
284
|
+
"requires_execution": False,
|
|
285
|
+
"_pending_tool_channel": None,
|
|
286
|
+
"_pending_tool_calls": [],
|
|
287
|
+
"_dangerous_tool_call_ids": [],
|
|
288
|
+
"_approved_tool_call_ids": [],
|
|
289
|
+
"_denied_tool_call_ids": [],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# ResumedSession
|
|
293
|
+
@dataclass
|
|
294
|
+
class ResumedSession:
|
|
295
|
+
session_id: str
|
|
296
|
+
events: list[dict]
|
|
297
|
+
session_start: dict | None
|
|
298
|
+
thread_id: str = ""
|
|
299
|
+
|
|
300
|
+
# CLI display state
|
|
301
|
+
selected_mode: str = "normal"
|
|
302
|
+
executed_mode: str = "normal"
|
|
303
|
+
is_session_started: bool = False
|
|
304
|
+
|
|
305
|
+
# Graph state
|
|
306
|
+
last_state: dict | None = None
|
|
307
|
+
checkpoint_thread_id: str | None = None
|
|
308
|
+
checkpoint_source: str = "sqlite" # "sqlite" | "reconstructed" | "missing"
|
|
309
|
+
|
|
310
|
+
# Parked interrupt
|
|
311
|
+
pending_interrupt: dict | None = None
|
|
312
|
+
|
|
313
|
+
# Any warnings worth surfacing to the user
|
|
314
|
+
warnings: list[str] = field(default_factory=list)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# Public entry
|
|
318
|
+
async def load_session(
|
|
319
|
+
*,
|
|
320
|
+
sessions_dir: Path,
|
|
321
|
+
session_id: str,
|
|
322
|
+
graph,
|
|
323
|
+
session_config: dict | RunnableConfig,
|
|
324
|
+
reconstruct_if_missing: bool = True,
|
|
325
|
+
) -> ResumedSession:
|
|
326
|
+
"""
|
|
327
|
+
Load a session: event chain + CLI state + graph state + interrupt.
|
|
328
|
+
"""
|
|
329
|
+
events = load_chain(sessions_dir, session_id)
|
|
330
|
+
if not events:
|
|
331
|
+
raise ValueError(f"session {session_id} has no events")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
session_start_ev = next(
|
|
335
|
+
(e for e in events if e.get("type") == "session_start"), None
|
|
336
|
+
)
|
|
337
|
+
prompts = (session_start_ev or {}).get("data", {}).get("system_prompts")
|
|
338
|
+
|
|
339
|
+
display = derive_display_state(events)
|
|
340
|
+
pending = detect_pending_interrupt(events)
|
|
341
|
+
|
|
342
|
+
resumed = ResumedSession(
|
|
343
|
+
session_id=session_id,
|
|
344
|
+
events=events,
|
|
345
|
+
session_start=session_start_ev,
|
|
346
|
+
selected_mode=display["selected_mode"],
|
|
347
|
+
executed_mode=display["executed_mode"],
|
|
348
|
+
is_session_started=display["is_session_started"],
|
|
349
|
+
pending_interrupt=pending,
|
|
350
|
+
checkpoint_thread_id=session_config.get("configurable", {}).get("thread_id"),
|
|
351
|
+
)
|
|
352
|
+
resumed.thread_id = current_thread_id(
|
|
353
|
+
events,
|
|
354
|
+
default=session_config.get("configurable", {}).get("thread_id") or session_id,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# graph state via checkpointer (authoritative)
|
|
360
|
+
try:
|
|
361
|
+
snapshot = await graph.aget_state(session_config)
|
|
362
|
+
values = snapshot.values if snapshot is not None else None
|
|
363
|
+
# LangGraph returns an empty snapshot object rather than None when
|
|
364
|
+
# the thread exists but has no checkpoint; treat empty as missing.
|
|
365
|
+
if values and any(values.get(k) for k in ("coder_messages", "planner_messages")):
|
|
366
|
+
resumed.last_state = values
|
|
367
|
+
resumed.checkpoint_source = "sqlite"
|
|
368
|
+
return resumed
|
|
369
|
+
except Exception as e:
|
|
370
|
+
resumed.warnings.append(f"checkpointer read failed: {e!r}")
|
|
371
|
+
|
|
372
|
+
# fallback: rebuild from log
|
|
373
|
+
if reconstruct_if_missing:
|
|
374
|
+
resumed.warnings.append(
|
|
375
|
+
"checkpointer had no state for this thread; "
|
|
376
|
+
"reconstructed from event log (best-effort, lossy)."
|
|
377
|
+
)
|
|
378
|
+
try:
|
|
379
|
+
resumed.last_state = reconstruct_agent_state(events, system_prompts=prompts)
|
|
380
|
+
resumed.checkpoint_source = "reconstructed"
|
|
381
|
+
except Exception as e:
|
|
382
|
+
resumed.warnings.append(f"reconstruction failed: {e!r}")
|
|
383
|
+
resumed.last_state = None
|
|
384
|
+
resumed.checkpoint_source = "missing"
|
|
385
|
+
else:
|
|
386
|
+
resumed.checkpoint_source = "missing"
|
|
387
|
+
|
|
388
|
+
return resumed
|
|
389
|
+
|
|
390
|
+
def _extract_tail_timestamp(f, file_size: int, chunk_size: int = 4096) -> Any | None:
|
|
391
|
+
"""Seek to the end of the file and find the last timestamp without reading the whole file."""
|
|
392
|
+
if file_size == 0:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
read_size = min(file_size, chunk_size)
|
|
396
|
+
f.seek(file_size - read_size, os.SEEK_SET)
|
|
397
|
+
chunk = f.read().splitlines()
|
|
398
|
+
|
|
399
|
+
# Iterate backwards through lines in the tail chunk
|
|
400
|
+
for line in reversed(chunk):
|
|
401
|
+
if not line.strip() or b'"timestamp"' not in line:
|
|
402
|
+
continue
|
|
403
|
+
try:
|
|
404
|
+
ev = fast_json.loads(line)
|
|
405
|
+
if ts := ev.get("timestamp"):
|
|
406
|
+
return ts
|
|
407
|
+
except Exception:
|
|
408
|
+
continue
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
def _parse_session_file(entry: os.DirEntry) -> dict[str, Any] | None:
|
|
412
|
+
session_id = entry.name.removesuffix(".jsonl")
|
|
413
|
+
size_bytes = entry.stat().st_size
|
|
414
|
+
|
|
415
|
+
if size_bytes == 0:
|
|
416
|
+
return {
|
|
417
|
+
"session_id": session_id,
|
|
418
|
+
"started_at": None,
|
|
419
|
+
"last_event_at": None,
|
|
420
|
+
"first_message": None,
|
|
421
|
+
"size_bytes": 0,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
first_user = None
|
|
425
|
+
started_at = None
|
|
426
|
+
last_ts = None
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
# Read in binary mode to avoid text-decoding overhead during seeks
|
|
430
|
+
with open(entry.path, "rb") as f:
|
|
431
|
+
# 1. Head scan: read until we find started_at and first_user
|
|
432
|
+
lines_checked = 0
|
|
433
|
+
for raw_line in f:
|
|
434
|
+
lines_checked += 1
|
|
435
|
+
if not raw_line.strip():
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
# Fast string checks before running JSON parser
|
|
439
|
+
is_start = b'"session_start"' in raw_line
|
|
440
|
+
is_user = b'"user_message"' in raw_line
|
|
441
|
+
|
|
442
|
+
if is_start or is_user:
|
|
443
|
+
try:
|
|
444
|
+
ev = fast_json.loads(raw_line)
|
|
445
|
+
except Exception:
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
if is_start and started_at is None and ev.get("type") == "session_start":
|
|
449
|
+
started_at = ev.get("timestamp")
|
|
450
|
+
|
|
451
|
+
if is_user and first_user is None and ev.get("type") == "user_message":
|
|
452
|
+
first_user = (ev.get("data") or {}).get("content")
|
|
453
|
+
|
|
454
|
+
# Early exit once both fields are located or threshold reached
|
|
455
|
+
if started_at is not None and first_user is not None:
|
|
456
|
+
break
|
|
457
|
+
if lines_checked >= 100:
|
|
458
|
+
break
|
|
459
|
+
|
|
460
|
+
# 2. Tail scan: jump straight to the end for the last timestamp
|
|
461
|
+
last_ts = _extract_tail_timestamp(f, size_bytes)
|
|
462
|
+
|
|
463
|
+
except OSError:
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
"session_id": session_id,
|
|
468
|
+
"started_at": started_at,
|
|
469
|
+
"last_event_at": last_ts,
|
|
470
|
+
"first_message": first_user,
|
|
471
|
+
"size_bytes": size_bytes,
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# CLI helper: list sessions in a workspace
|
|
476
|
+
def list_sessions(sessions_dir: Path, max_workers: int = 8) -> list[dict[str, Any]]:
|
|
477
|
+
|
|
478
|
+
"""Scan the workspace session directory efficiently using head/tail seeking and thread concurrency."""
|
|
479
|
+
if not sessions_dir.exists():
|
|
480
|
+
return []
|
|
481
|
+
|
|
482
|
+
# os.scandir avoids creating heavy Path objects and caches stat results on Linux/macOS/Windows
|
|
483
|
+
try:
|
|
484
|
+
entries = [
|
|
485
|
+
e for e in os.scandir(sessions_dir)
|
|
486
|
+
if e.is_file() and e.name.endswith(".jsonl")
|
|
487
|
+
]
|
|
488
|
+
except OSError:
|
|
489
|
+
return []
|
|
490
|
+
|
|
491
|
+
entries.sort(key=lambda e: e.name, reverse=True)
|
|
492
|
+
if not entries:
|
|
493
|
+
return []
|
|
494
|
+
|
|
495
|
+
# Parallelize file reading across threads (pure I/O bound)
|
|
496
|
+
results: list[dict[str, Any]] = []
|
|
497
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=min(max_workers, len(entries))) as executor:
|
|
498
|
+
for res in executor.map(_parse_session_file, entries):
|
|
499
|
+
if res is not None:
|
|
500
|
+
results.append(res)
|
|
501
|
+
|
|
502
|
+
return results
|
|
503
|
+
|
|
504
|
+
def current_thread_id(events: list[dict], default: str) -> str:
|
|
505
|
+
"""Walk events; the last fork event's thread_id wins."""
|
|
506
|
+
tid = default
|
|
507
|
+
for ev in events:
|
|
508
|
+
if ev.get("type") == "fork":
|
|
509
|
+
t = (ev.get("data") or {}).get("thread_id")
|
|
510
|
+
if t:
|
|
511
|
+
tid = t
|
|
512
|
+
return tid
|
|
513
|
+
|
|
514
|
+
def list_user_messages(events: list[dict]) -> list[dict]:
|
|
515
|
+
"""
|
|
516
|
+
Enumerate user messages in the chain, tagging each with a `shadowed`
|
|
517
|
+
flag if it has been compacted away by a later compaction event.
|
|
518
|
+
"""
|
|
519
|
+
shadowed, _ = build_shadow_map(events)
|
|
520
|
+
|
|
521
|
+
out: list[dict] = []
|
|
522
|
+
idx = 0
|
|
523
|
+
for ev in events:
|
|
524
|
+
if ev.get("type") != "user_message":
|
|
525
|
+
continue
|
|
526
|
+
idx += 1
|
|
527
|
+
d = ev.get("data") or {}
|
|
528
|
+
out.append({
|
|
529
|
+
"index": idx,
|
|
530
|
+
"uuid": ev.get("uuid"),
|
|
531
|
+
"parent_uuid": ev.get("parentUuid"),
|
|
532
|
+
"content": (d.get("content") or "").strip(),
|
|
533
|
+
"mode": d.get("mode"),
|
|
534
|
+
"timestamp": ev.get("timestamp"),
|
|
535
|
+
"shadowed": ev.get("uuid") in shadowed,
|
|
536
|
+
})
|
|
537
|
+
return out
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def list_leaves(events: list[dict]) -> list[str]:
|
|
541
|
+
"""All uuids that never appear as a parent - i.e. current branch tips."""
|
|
542
|
+
used_as_parent = {ev.get("parentUuid") for ev in events if ev.get("parentUuid")}
|
|
543
|
+
leaves = [ev["uuid"] for ev in events if ev.get("uuid") and ev["uuid"] not in used_as_parent]
|
|
544
|
+
# uuid7 sorts lexicographically by creation time
|
|
545
|
+
return sorted(leaves, reverse=True)
|
|
546
|
+
|
|
547
|
+
def load_all_events(sessions_dir: Path, session_id: str) -> list[dict]:
|
|
548
|
+
"""Read every event in the file (all branches), in file order."""
|
|
549
|
+
path = sessions_dir / f"{session_id}.jsonl"
|
|
550
|
+
if not path.exists():
|
|
551
|
+
return []
|
|
552
|
+
events: list[dict] = []
|
|
553
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
554
|
+
for line in f:
|
|
555
|
+
line = line.strip()
|
|
556
|
+
if not line:
|
|
557
|
+
continue
|
|
558
|
+
try:
|
|
559
|
+
events.append(json.loads(line))
|
|
560
|
+
except json.JSONDecodeError:
|
|
561
|
+
continue
|
|
562
|
+
return events
|
|
563
|
+
|
|
564
|
+
def build_shadow_map(events: list[dict]) -> tuple[set[str], dict[str, dict]]:
|
|
565
|
+
"""
|
|
566
|
+
Return (shadowed_uuids, compaction_by_uuid).
|
|
567
|
+
|
|
568
|
+
shadowed_uuids: every uuid covered by some compaction event on this chain.
|
|
569
|
+
compaction_by_uuid: compaction event uuid -> its data payload.
|
|
570
|
+
"""
|
|
571
|
+
shadowed: set[str] = set()
|
|
572
|
+
compaction_by_uuid: dict[str, dict] = {}
|
|
573
|
+
for ev in events:
|
|
574
|
+
if ev.get("type") != "compaction":
|
|
575
|
+
continue
|
|
576
|
+
d = ev.get("data") or {}
|
|
577
|
+
for u in d.get("range_uuids") or []:
|
|
578
|
+
shadowed.add(u)
|
|
579
|
+
compaction_by_uuid[ev["uuid"]] = d
|
|
580
|
+
return shadowed, compaction_by_uuid
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _repair_orphaned_tool_calls(msgs: list[BaseMessage]) -> None:
|
|
585
|
+
"""Ensure every AIMessage.tool_calls entry has a matching ToolMessage."""
|
|
586
|
+
call_ids = set()
|
|
587
|
+
result_ids = set()
|
|
588
|
+
for m in msgs:
|
|
589
|
+
if isinstance(m, AIMessage):
|
|
590
|
+
for tc in (getattr(m, "tool_calls", None) or []):
|
|
591
|
+
if tc.get("id"):
|
|
592
|
+
call_ids.add(tc["id"])
|
|
593
|
+
elif isinstance(m, ToolMessage):
|
|
594
|
+
if getattr(m, "tool_call_id", None):
|
|
595
|
+
result_ids.add(m.tool_call_id)
|
|
596
|
+
|
|
597
|
+
orphaned = call_ids - result_ids
|
|
598
|
+
if not orphaned:
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
rebuilt: list[BaseMessage] = []
|
|
602
|
+
for m in msgs:
|
|
603
|
+
rebuilt.append(m)
|
|
604
|
+
if isinstance(m, AIMessage) and m.tool_calls:
|
|
605
|
+
for tc in m.tool_calls:
|
|
606
|
+
if tc.get("id") in orphaned:
|
|
607
|
+
rebuilt.append(ToolMessage(
|
|
608
|
+
content=(
|
|
609
|
+
"[Tool execution was interrupted before completion — "
|
|
610
|
+
"no result was recorded. Do not assume the tool ran.]"
|
|
611
|
+
),
|
|
612
|
+
tool_call_id=tc["id"],
|
|
613
|
+
name=tc.get("name") or "unknown",
|
|
614
|
+
status="error",
|
|
615
|
+
))
|
|
616
|
+
msgs[:] = rebuilt
|