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,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Translate LangChain `astream_events(version='v2')` frames into events
|
|
3
|
+
appended to an EventLog.
|
|
4
|
+
|
|
5
|
+
Covers:
|
|
6
|
+
assistant_message from on_chat_model_start/stream/end
|
|
7
|
+
tool_result from on_tool_start/end/error
|
|
8
|
+
|
|
9
|
+
Design rules:
|
|
10
|
+
- The emitter is a passive translator. It never mutates LangGraph state,
|
|
11
|
+
never raises past its own boundary, and never blocks the display path.
|
|
12
|
+
- All appends go through EventLog.append, which is already lock-serialized.
|
|
13
|
+
- Streaming chunks are accumulated locally using AIMessageChunk.__add__,
|
|
14
|
+
which correctly merges content, tool_call_chunks, usage, and
|
|
15
|
+
additional_kwargs (incl. reasoning_content for reasoning models).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from langchain_core.messages import (
|
|
25
|
+
AIMessage,
|
|
26
|
+
AIMessageChunk,
|
|
27
|
+
BaseMessage,
|
|
28
|
+
ToolMessage,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from vamp_cli.persistence.event_log import EventLog
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# helpers
|
|
35
|
+
def _content_to_str(content: Any) -> str:
|
|
36
|
+
"""Normalize message content to plain text."""
|
|
37
|
+
if content is None:
|
|
38
|
+
return ""
|
|
39
|
+
if isinstance(content, str):
|
|
40
|
+
return content
|
|
41
|
+
if isinstance(content, list):
|
|
42
|
+
parts: list[str] = []
|
|
43
|
+
for block in content:
|
|
44
|
+
if isinstance(block, str):
|
|
45
|
+
parts.append(block)
|
|
46
|
+
elif isinstance(block, dict):
|
|
47
|
+
# Anthropic / OpenAI content blocks
|
|
48
|
+
if block.get("type") in ("text", "output_text") and "text" in block:
|
|
49
|
+
parts.append(str(block["text"]))
|
|
50
|
+
elif block.get("type") == "thinking" and "thinking" in block:
|
|
51
|
+
parts.append(str(block["thinking"]))
|
|
52
|
+
# other block types (image, tool_use, etc) skipped
|
|
53
|
+
return "".join(parts)
|
|
54
|
+
return str(content)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_reasoning(msg: Any) -> str:
|
|
58
|
+
"""Pull reasoning text out of whatever field the provider used."""
|
|
59
|
+
if msg is None:
|
|
60
|
+
return ""
|
|
61
|
+
direct = getattr(msg, "reasoning_content", None)
|
|
62
|
+
if isinstance(direct, str) and direct:
|
|
63
|
+
return direct
|
|
64
|
+
extra = getattr(msg, "additional_kwargs", None) or {}
|
|
65
|
+
for key in ("reasoning_content", "reasoning", "thinking"):
|
|
66
|
+
val = extra.get(key)
|
|
67
|
+
if isinstance(val, str) and val:
|
|
68
|
+
return val
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_tool_calls(msg: Any) -> list[dict]:
|
|
73
|
+
"""
|
|
74
|
+
Return normalized tool_calls as plain dicts.
|
|
75
|
+
|
|
76
|
+
LangChain normalizes them into {name, args, id, type} on AIMessage.
|
|
77
|
+
Chunks may instead expose `tool_call_chunks`, which merge into the
|
|
78
|
+
same shape as a best-effort.
|
|
79
|
+
"""
|
|
80
|
+
if msg is None:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
calls = getattr(msg, "tool_calls", None)
|
|
84
|
+
if calls:
|
|
85
|
+
out: list[dict] = []
|
|
86
|
+
for tc in calls:
|
|
87
|
+
if isinstance(tc, dict):
|
|
88
|
+
out.append({
|
|
89
|
+
"id": tc.get("id"),
|
|
90
|
+
"name": tc.get("name"),
|
|
91
|
+
"args": tc.get("args") or {},
|
|
92
|
+
})
|
|
93
|
+
else:
|
|
94
|
+
out.append({
|
|
95
|
+
"id": getattr(tc, "id", None),
|
|
96
|
+
"name": getattr(tc, "name", None),
|
|
97
|
+
"args": getattr(tc, "args", {}) or {},
|
|
98
|
+
})
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
chunks = getattr(msg, "tool_call_chunks", None) or []
|
|
102
|
+
merged: dict[int, dict] = {}
|
|
103
|
+
for i, c in enumerate(chunks):
|
|
104
|
+
idx = c.get("index", i) if isinstance(c, dict) else i
|
|
105
|
+
bucket = merged.setdefault(idx, {"id": None, "name": None, "args_str": ""})
|
|
106
|
+
if isinstance(c, dict):
|
|
107
|
+
bucket["id"] = bucket["id"] or c.get("id")
|
|
108
|
+
bucket["name"] = bucket["name"] or c.get("name")
|
|
109
|
+
if c.get("args"):
|
|
110
|
+
bucket["args_str"] += str(c["args"])
|
|
111
|
+
out2: list[dict] = []
|
|
112
|
+
for idx in sorted(merged):
|
|
113
|
+
b = merged[idx]
|
|
114
|
+
args: Any = {}
|
|
115
|
+
if b["args_str"]:
|
|
116
|
+
try:
|
|
117
|
+
import json
|
|
118
|
+
args = json.loads(b["args_str"])
|
|
119
|
+
except Exception:
|
|
120
|
+
args = {"_raw": b["args_str"]}
|
|
121
|
+
out2.append({"id": b["id"], "name": b["name"], "args": args})
|
|
122
|
+
return out2
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _unwrap_chat_output(output: Any) -> BaseMessage | None:
|
|
126
|
+
"""
|
|
127
|
+
on_chat_model_end.data.output is either an AIMessage/AIMessageChunk
|
|
128
|
+
or an LLMResult wrapping one. Unwrap both.
|
|
129
|
+
"""
|
|
130
|
+
if output is None:
|
|
131
|
+
return None
|
|
132
|
+
if hasattr(output, "generations"):
|
|
133
|
+
gens = getattr(output, "generations") or []
|
|
134
|
+
if gens and gens[0]:
|
|
135
|
+
return getattr(gens[0][0], "message", None)
|
|
136
|
+
return None
|
|
137
|
+
if isinstance(output, (AIMessage, AIMessageChunk)):
|
|
138
|
+
return output
|
|
139
|
+
return output if isinstance(output, BaseMessage) else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _truncate(text: str, limit: int) -> tuple[str, bool]:
|
|
143
|
+
if len(text) <= limit:
|
|
144
|
+
return text, False
|
|
145
|
+
# keep head and tail so both the context and the error message survive
|
|
146
|
+
head = limit * 2 // 3
|
|
147
|
+
tail = limit - head
|
|
148
|
+
return f"{text[:head]}\n...[{len(text) - limit} bytes elided]...\n{text[-tail:]}", True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# EventEmitter
|
|
152
|
+
class EventEmitter:
|
|
153
|
+
"""
|
|
154
|
+
Consumes astream_events frames and appends persistence events.
|
|
155
|
+
|
|
156
|
+
Usage:
|
|
157
|
+
emitter = EventEmitter(log)
|
|
158
|
+
async for event in graph.astream_events(payload, config=cfg, version="v2"):
|
|
159
|
+
await emitter.consume(event)
|
|
160
|
+
display logic unchanged
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, log: EventLog, *, tool_output_limit: int = 16_000) -> None:
|
|
164
|
+
self.log = log
|
|
165
|
+
self._tool_output_limit = tool_output_limit
|
|
166
|
+
|
|
167
|
+
# run_id -> in-flight chat state
|
|
168
|
+
self._chat: dict[str, dict[str, Any]] = {}
|
|
169
|
+
|
|
170
|
+
# run_id -> in-flight tool state
|
|
171
|
+
self._tools: dict[str, dict[str, Any]] = {}
|
|
172
|
+
|
|
173
|
+
# tool_call_id -> originating node ("coder" | "planner" | "tool")
|
|
174
|
+
# Populated when an assistant_message carrying tool_calls is emitted,
|
|
175
|
+
# consumed when the matching tool_result is emitted.
|
|
176
|
+
self._tool_call_to_node: dict[str, str | None] = {}
|
|
177
|
+
|
|
178
|
+
# last input_tokens reported by any model call in this turn
|
|
179
|
+
self.last_input_tokens: int = 0
|
|
180
|
+
|
|
181
|
+
# public entry point
|
|
182
|
+
async def consume(self, event: dict) -> None:
|
|
183
|
+
"""Route one astream_events frame. Never raises."""
|
|
184
|
+
kind = event.get("event", "")
|
|
185
|
+
handler = _HANDLERS.get(kind)
|
|
186
|
+
if handler is None:
|
|
187
|
+
return
|
|
188
|
+
try:
|
|
189
|
+
await handler(self, event)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
# Persistence must never break the run. Report and move on.
|
|
192
|
+
print(f"[persistence] failed to emit '{kind}': {e!r}", file=sys.stderr)
|
|
193
|
+
|
|
194
|
+
# chat model
|
|
195
|
+
async def _on_chat_start(self, event: dict) -> None:
|
|
196
|
+
run_id = event["run_id"]
|
|
197
|
+
self._chat[run_id] = {
|
|
198
|
+
"chunk": None, # accumulated AIMessageChunk
|
|
199
|
+
"meta": event.get("metadata") or {},
|
|
200
|
+
"started_at": time.time(),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async def _on_chat_stream(self, event: dict) -> None:
|
|
204
|
+
run_id = event.get("run_id")
|
|
205
|
+
buf = self._chat.get(run_id) # type: ignore
|
|
206
|
+
if buf is None:
|
|
207
|
+
return
|
|
208
|
+
chunk = (event.get("data") or {}).get("chunk")
|
|
209
|
+
if chunk is None:
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
buf["chunk"] = chunk if buf["chunk"] is None else buf["chunk"] + chunk
|
|
213
|
+
except Exception:
|
|
214
|
+
# merge failure is rare and non-fatal - keep what we have
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
async def _on_chat_end(self, event: dict) -> None:
|
|
218
|
+
run_id = event.get("run_id")
|
|
219
|
+
buf = self._chat.pop(run_id, None) #type: ignore
|
|
220
|
+
if buf is None:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
accumulated: AIMessageChunk | None = buf["chunk"]
|
|
224
|
+
final_msg = _unwrap_chat_output((event.get("data") or {}).get("output"))
|
|
225
|
+
|
|
226
|
+
# Prefer the accumulated stream (it has reasoning_content that the
|
|
227
|
+
# final message often drops). Supplement with final_msg when needed.
|
|
228
|
+
content = _content_to_str(getattr(accumulated, "content", "")) if accumulated else ""
|
|
229
|
+
reasoning = _extract_reasoning(accumulated) if accumulated else ""
|
|
230
|
+
tool_calls = _extract_tool_calls(accumulated) if accumulated else []
|
|
231
|
+
|
|
232
|
+
if final_msg is not None:
|
|
233
|
+
if not content:
|
|
234
|
+
content = _content_to_str(getattr(final_msg, "content", ""))
|
|
235
|
+
if not reasoning:
|
|
236
|
+
reasoning = _extract_reasoning(final_msg)
|
|
237
|
+
if not tool_calls:
|
|
238
|
+
tool_calls = _extract_tool_calls(final_msg)
|
|
239
|
+
|
|
240
|
+
usage = None
|
|
241
|
+
if final_msg is not None:
|
|
242
|
+
um = getattr(final_msg, "usage_metadata", None)
|
|
243
|
+
if um:
|
|
244
|
+
usage = dict(um)
|
|
245
|
+
|
|
246
|
+
if usage and isinstance(usage.get("input_tokens"), int):
|
|
247
|
+
self.last_input_tokens = usage["input_tokens"]
|
|
248
|
+
|
|
249
|
+
meta = buf.get("meta") or {}
|
|
250
|
+
node = meta.get("langgraph_node")
|
|
251
|
+
|
|
252
|
+
# Remember which node each tool call came from so tool_result
|
|
253
|
+
# events can carry the same attribution.
|
|
254
|
+
for tc in tool_calls:
|
|
255
|
+
tid = tc.get("id")
|
|
256
|
+
if tid:
|
|
257
|
+
self._tool_call_to_node[tid] = node
|
|
258
|
+
|
|
259
|
+
self.log.emit("assistant_message", {
|
|
260
|
+
"node": node,
|
|
261
|
+
"content": content,
|
|
262
|
+
"reasoning_content": reasoning or None,
|
|
263
|
+
"tool_calls": tool_calls,
|
|
264
|
+
"usage": usage,
|
|
265
|
+
"model": meta.get("ls_model_name"),
|
|
266
|
+
"duration_ms": int((time.time() - buf["started_at"]) * 1000),
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
# tools ---------------------------------------------------------
|
|
270
|
+
async def _on_tool_start(self, event: dict) -> None:
|
|
271
|
+
run_id = event.get("run_id")
|
|
272
|
+
data = event.get("data") or {}
|
|
273
|
+
meta = event.get("metadata") or {}
|
|
274
|
+
|
|
275
|
+
# LangChain names the tool in `event["name"]`, the args in data["input"].
|
|
276
|
+
self._tools[run_id] = { # type: ignore
|
|
277
|
+
"tool_name": event.get("name"),
|
|
278
|
+
"tool_call_id": data.get("tool_call_id"),
|
|
279
|
+
"input": data.get("input"),
|
|
280
|
+
"node_hint": meta.get("langgraph_node"),
|
|
281
|
+
"started_at": time.time(),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async def _on_tool_end(self, event: dict) -> None:
|
|
285
|
+
run_id = event.get("run_id")
|
|
286
|
+
meta = self._tools.pop(run_id, {}) # type: ignore
|
|
287
|
+
output = (event.get("data") or {}).get("output")
|
|
288
|
+
|
|
289
|
+
tool_call_id = meta.get("tool_call_id")
|
|
290
|
+
tool_name = meta.get("tool_name")
|
|
291
|
+
status = "success"
|
|
292
|
+
content = ""
|
|
293
|
+
|
|
294
|
+
if isinstance(output, ToolMessage):
|
|
295
|
+
tool_call_id = getattr(output, "tool_call_id", None) or tool_call_id
|
|
296
|
+
tool_name = getattr(output, "name", None) or tool_name
|
|
297
|
+
content = _content_to_str(getattr(output, "content", ""))
|
|
298
|
+
status = getattr(output, "status", None) or "success"
|
|
299
|
+
else:
|
|
300
|
+
content = _content_to_str(output)
|
|
301
|
+
|
|
302
|
+
text, truncated = _truncate(content, self._tool_output_limit)
|
|
303
|
+
|
|
304
|
+
# Prefer the model-node when the assistant emitted the call.
|
|
305
|
+
# Fall back to langgraph_node from the tool event,
|
|
306
|
+
# which will usually be "execute_tools" and thus less useful.
|
|
307
|
+
node = self._tool_call_to_node.get(tool_call_id) or meta.get("node_hint")
|
|
308
|
+
|
|
309
|
+
self.log.emit("tool_result", {
|
|
310
|
+
"tool_call_id": tool_call_id,
|
|
311
|
+
"tool_name": tool_name,
|
|
312
|
+
"node": node,
|
|
313
|
+
"status": status,
|
|
314
|
+
"content": text,
|
|
315
|
+
"truncated": truncated,
|
|
316
|
+
"content_len": len(content),
|
|
317
|
+
"duration_ms": int((time.time() - meta.get("started_at", time.time())) * 1000),
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
async def _on_tool_error(self, event: dict) -> None:
|
|
321
|
+
run_id = event.get("run_id")
|
|
322
|
+
meta = self._tools.pop(run_id, {}) # type: ignore
|
|
323
|
+
err = (event.get("data") or {}).get("error")
|
|
324
|
+
|
|
325
|
+
tool_call_id = meta.get("tool_call_id")
|
|
326
|
+
node = self._tool_call_to_node.get(tool_call_id) or meta.get("node_hint")
|
|
327
|
+
|
|
328
|
+
self.log.emit("tool_result", {
|
|
329
|
+
"tool_call_id": tool_call_id,
|
|
330
|
+
"tool_name": meta.get("tool_name"),
|
|
331
|
+
"node": node,
|
|
332
|
+
"status": "error",
|
|
333
|
+
"content": f"{type(err).__name__}: {err}" if err else "unknown error",
|
|
334
|
+
"truncated": False,
|
|
335
|
+
"content_len": 0,
|
|
336
|
+
"duration_ms": int((time.time() - meta.get("started_at", time.time())) * 1000),
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
def emit_tool_result(
|
|
340
|
+
self,
|
|
341
|
+
*,
|
|
342
|
+
tool_call_id: str,
|
|
343
|
+
tool_name: str,
|
|
344
|
+
node: str | None,
|
|
345
|
+
status: str,
|
|
346
|
+
content: str,
|
|
347
|
+
duration_ms: int,
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Public hook for callers that bypass LangChain's callback stream."""
|
|
350
|
+
text, truncated = _truncate(content or "", self._tool_output_limit)
|
|
351
|
+
self.log.emit("tool_result", {
|
|
352
|
+
"tool_call_id": tool_call_id,
|
|
353
|
+
"tool_name": tool_name,
|
|
354
|
+
"node": node,
|
|
355
|
+
"status": status,
|
|
356
|
+
"content": text,
|
|
357
|
+
"truncated": truncated,
|
|
358
|
+
"content_len": len(content or ""),
|
|
359
|
+
"duration_ms": duration_ms,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# dispatch table - populated after the class to keep the definition clean
|
|
364
|
+
_HANDLERS = {
|
|
365
|
+
"on_chat_model_start": EventEmitter._on_chat_start,
|
|
366
|
+
"on_chat_model_stream": EventEmitter._on_chat_stream,
|
|
367
|
+
"on_chat_model_end": EventEmitter._on_chat_end,
|
|
368
|
+
"on_tool_start": EventEmitter._on_tool_start,
|
|
369
|
+
"on_tool_end": EventEmitter._on_tool_end,
|
|
370
|
+
"on_tool_error": EventEmitter._on_tool_error,
|
|
371
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Append-only JSONL event log for a single agent session.
|
|
3
|
+
|
|
4
|
+
File layout (per workspace):
|
|
5
|
+
<user-home-dir>/.vamp/sessions/<sessionId>.jsonl # append-only event log
|
|
6
|
+
<user-home-dir>/.vamp/sessions/<sessionId>.head # sidecar: current leaf uuid
|
|
7
|
+
|
|
8
|
+
Concurrency model:
|
|
9
|
+
- One background writer task drains an asyncio.Queue in FIFO order.
|
|
10
|
+
- `emit()` is fire-and-forget: enqueue and return. Display path is free.
|
|
11
|
+
- `append()` enqueues and awaits the write. Use for durability-critical
|
|
12
|
+
events (user_message, interrupt_request, interrupt_response, ...).
|
|
13
|
+
- `parentUuid` chaining happens *inside* the writer task, so it is
|
|
14
|
+
race-free even under bursts of concurrent emit() calls.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Iterable, Iterator
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# uuid7 - time-sortable, canonical-string form
|
|
32
|
+
def uuid7() -> str:
|
|
33
|
+
ms = int(time.time() * 1000) & ((1 << 48) - 1)
|
|
34
|
+
rand_a = int.from_bytes(os.urandom(2), "big") & 0x0FFF
|
|
35
|
+
rand_b = int.from_bytes(os.urandom(8), "big") & ((1 << 62) - 1)
|
|
36
|
+
value = (ms << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
|
|
37
|
+
return str(uuid.UUID(int=value))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# git branch
|
|
41
|
+
def _read_git_branch(cwd: str) -> str | None:
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
45
|
+
cwd=cwd, capture_output=True, text=True, timeout=1,
|
|
46
|
+
)
|
|
47
|
+
if result.returncode == 0:
|
|
48
|
+
branch = result.stdout.strip()
|
|
49
|
+
return branch or None
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# redaction hook
|
|
56
|
+
Redactor = Callable[[str, dict[str, Any]], dict[str, Any]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def identity_redactor(event_type: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# fsync policy
|
|
64
|
+
DEFAULT_FSYNC_TYPES = frozenset({
|
|
65
|
+
"session_start",
|
|
66
|
+
"session_end",
|
|
67
|
+
"user_message",
|
|
68
|
+
"mode_switch",
|
|
69
|
+
"interrupt_request",
|
|
70
|
+
"interrupt_response",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Queue item: (event_type, data, done_future_or_None)
|
|
75
|
+
# _QueueItem type
|
|
76
|
+
_QueueItem = tuple[str, dict[str, Any], "asyncio.Future[str] | None", "str | None"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# EventLog
|
|
80
|
+
class EventLog:
|
|
81
|
+
"""Async append-only writer for one session, backed by a writer task."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
session_id: str,
|
|
87
|
+
path: Path,
|
|
88
|
+
head_path: Path,
|
|
89
|
+
cwd: str,
|
|
90
|
+
version: str,
|
|
91
|
+
git_branch: str | None,
|
|
92
|
+
redactor: Redactor,
|
|
93
|
+
fsync_types: frozenset[str],
|
|
94
|
+
) -> None:
|
|
95
|
+
self.session_id = session_id
|
|
96
|
+
self.path = path
|
|
97
|
+
self.head_path = head_path
|
|
98
|
+
self.cwd = cwd
|
|
99
|
+
self.version = version
|
|
100
|
+
self.git_branch = git_branch
|
|
101
|
+
self._redactor = redactor
|
|
102
|
+
self._fsync_types = fsync_types
|
|
103
|
+
|
|
104
|
+
self._file = None
|
|
105
|
+
self._last_uuid: str | None = None
|
|
106
|
+
self._queue: asyncio.Queue[_QueueItem | None] = asyncio.Queue()
|
|
107
|
+
self._writer_task: asyncio.Task[None] | None = None
|
|
108
|
+
self._closed = False
|
|
109
|
+
|
|
110
|
+
# construction
|
|
111
|
+
@classmethod
|
|
112
|
+
async def open(
|
|
113
|
+
cls,
|
|
114
|
+
session_id: str,
|
|
115
|
+
*,
|
|
116
|
+
workspace_root: str | os.PathLike[str],
|
|
117
|
+
version: str,
|
|
118
|
+
redactor: Redactor | None = None,
|
|
119
|
+
fsync_on: Iterable[str] | None = None,
|
|
120
|
+
cwd: str | None = None,
|
|
121
|
+
) -> "EventLog":
|
|
122
|
+
sessions_dir = Path.home() / ".vamp" / "sessions"
|
|
123
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
|
|
125
|
+
path = sessions_dir / f"{session_id}.jsonl"
|
|
126
|
+
head_path = sessions_dir / f"{session_id}.head"
|
|
127
|
+
cwd = cwd or str(Path.cwd())
|
|
128
|
+
|
|
129
|
+
log = cls(
|
|
130
|
+
session_id=session_id,
|
|
131
|
+
path=path,
|
|
132
|
+
head_path=head_path,
|
|
133
|
+
cwd=cwd,
|
|
134
|
+
version=version,
|
|
135
|
+
git_branch=_read_git_branch(cwd),
|
|
136
|
+
redactor=redactor or identity_redactor,
|
|
137
|
+
fsync_types=(
|
|
138
|
+
frozenset(fsync_on) if fsync_on is not None
|
|
139
|
+
else DEFAULT_FSYNC_TYPES
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if head_path.exists():
|
|
144
|
+
log._last_uuid = head_path.read_text(encoding="utf-8").strip() or None
|
|
145
|
+
elif path.exists():
|
|
146
|
+
log._last_uuid = _scan_leaf_uuid(path)
|
|
147
|
+
|
|
148
|
+
log._file = open(path, "a", encoding="utf-8", buffering=1)
|
|
149
|
+
log._writer_task = asyncio.create_task(log._writer_loop())
|
|
150
|
+
return log
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def head_uuid(self) -> str | None:
|
|
155
|
+
return self._last_uuid
|
|
156
|
+
|
|
157
|
+
def refresh_branch(self) -> None:
|
|
158
|
+
self.git_branch = _read_git_branch(self.cwd)
|
|
159
|
+
|
|
160
|
+
# writer task
|
|
161
|
+
async def _writer_loop(self) -> None:
|
|
162
|
+
while True:
|
|
163
|
+
item = await self._queue.get()
|
|
164
|
+
if item is None:
|
|
165
|
+
self._queue.task_done()
|
|
166
|
+
return
|
|
167
|
+
event_type, data, done, parent_uuid = item
|
|
168
|
+
try:
|
|
169
|
+
event_uuid = self._write_event(event_type, data, parent_uuid)
|
|
170
|
+
if done is not None and not done.done():
|
|
171
|
+
done.set_result(event_uuid)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
if done is not None and not done.done():
|
|
174
|
+
done.set_exception(e)
|
|
175
|
+
finally:
|
|
176
|
+
self._queue.task_done()
|
|
177
|
+
|
|
178
|
+
def _write_event(
|
|
179
|
+
self,
|
|
180
|
+
event_type: str,
|
|
181
|
+
data: dict[str, Any],
|
|
182
|
+
parent_uuid: str | None = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Synchronous - runs only inside the writer task."""
|
|
185
|
+
payload = self._redactor(event_type, dict(data))
|
|
186
|
+
|
|
187
|
+
event_uuid = uuid7()
|
|
188
|
+
envelope = {
|
|
189
|
+
"type": event_type,
|
|
190
|
+
"uuid": event_uuid,
|
|
191
|
+
"parentUuid": parent_uuid if parent_uuid is not None else self._last_uuid,
|
|
192
|
+
"sessionId": self.session_id,
|
|
193
|
+
"timestamp": _iso_now(),
|
|
194
|
+
"cwd": self.cwd,
|
|
195
|
+
"version": self.version,
|
|
196
|
+
"gitBranch": self.git_branch,
|
|
197
|
+
"data": payload,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
line = json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
|
|
201
|
+
self._file.write(line + "\n") # type: ignore
|
|
202
|
+
self._file.flush() # type: ignore
|
|
203
|
+
if event_type in self._fsync_types:
|
|
204
|
+
os.fsync(self._file.fileno()) # type: ignore
|
|
205
|
+
|
|
206
|
+
self._last_uuid = event_uuid
|
|
207
|
+
self.head_path.write_text(event_uuid, encoding="utf-8")
|
|
208
|
+
return event_uuid
|
|
209
|
+
|
|
210
|
+
# public API
|
|
211
|
+
def emit(self, event_type: str, data: dict[str, Any] | None = None, *, parent_uuid=None) -> None:
|
|
212
|
+
"""Fire-and-forget. Non-blocking. FIFO ordering guaranteed."""
|
|
213
|
+
if self._closed:
|
|
214
|
+
raise RuntimeError("EventLog is closed")
|
|
215
|
+
self._queue.put_nowait((event_type, dict(data or {}), None, parent_uuid))
|
|
216
|
+
|
|
217
|
+
async def append(
|
|
218
|
+
self,
|
|
219
|
+
event_type: str,
|
|
220
|
+
data: dict[str, Any] | None = None,
|
|
221
|
+
*,
|
|
222
|
+
parent_uuid=None,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Enqueue and wait for the durable write to complete."""
|
|
225
|
+
if self._closed:
|
|
226
|
+
raise RuntimeError("EventLog is closed")
|
|
227
|
+
done: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
228
|
+
await self._queue.put((event_type, dict(data or {}), done, parent_uuid))
|
|
229
|
+
return await done
|
|
230
|
+
|
|
231
|
+
async def flush(self) -> None:
|
|
232
|
+
"""Wait until every queued write has been processed."""
|
|
233
|
+
await self._queue.join()
|
|
234
|
+
|
|
235
|
+
async def close(self, reason: str = "user_exit") -> None:
|
|
236
|
+
if self._closed:
|
|
237
|
+
return
|
|
238
|
+
try:
|
|
239
|
+
await self.append("session_end", {"reason": reason})
|
|
240
|
+
await self.flush()
|
|
241
|
+
finally:
|
|
242
|
+
self._closed = True
|
|
243
|
+
await self._queue.put(None) # sentinel
|
|
244
|
+
if self._writer_task is not None:
|
|
245
|
+
try:
|
|
246
|
+
await self._writer_task
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
if self._file is not None:
|
|
250
|
+
self._file.flush()
|
|
251
|
+
try:
|
|
252
|
+
os.fsync(self._file.fileno())
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
self._file.close()
|
|
256
|
+
self._file = None
|
|
257
|
+
|
|
258
|
+
# branch control (fork; no writes, no truncation)
|
|
259
|
+
async def set_head(self, event_uuid: str) -> None:
|
|
260
|
+
"""Repoint the head. Flushes first so pending writes are durable."""
|
|
261
|
+
await self.flush()
|
|
262
|
+
self._last_uuid = event_uuid
|
|
263
|
+
self.head_path.write_text(event_uuid, encoding="utf-8")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# helpers
|
|
268
|
+
def _iso_now() -> str:
|
|
269
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _scan_leaf_uuid(path: Path) -> str | None:
|
|
273
|
+
all_uuids: list[str] = []
|
|
274
|
+
parents: set[str] = set()
|
|
275
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
276
|
+
for line in f:
|
|
277
|
+
line = line.strip()
|
|
278
|
+
if not line:
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
event = json.loads(line)
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
continue
|
|
284
|
+
u = event.get("uuid")
|
|
285
|
+
p = event.get("parentUuid")
|
|
286
|
+
if u:
|
|
287
|
+
all_uuids.append(u)
|
|
288
|
+
if p:
|
|
289
|
+
parents.add(p)
|
|
290
|
+
leaves = [u for u in all_uuids if u not in parents]
|
|
291
|
+
return max(leaves) if leaves else None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def iter_events(path: Path) -> Iterator[dict[str, Any]]:
|
|
295
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
296
|
+
for line in f:
|
|
297
|
+
line = line.strip()
|
|
298
|
+
if not line:
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
yield json.loads(line)
|
|
302
|
+
except json.JSONDecodeError:
|
|
303
|
+
continue
|