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.
Files changed (57) hide show
  1. vamp_cli/__init__.py +0 -0
  2. vamp_cli/agent/__init__.py +0 -0
  3. vamp_cli/agent/context_sync.py +72 -0
  4. vamp_cli/agent/graph.py +159 -0
  5. vamp_cli/agent/instructions.py +18 -0
  6. vamp_cli/agent/nodes.py +375 -0
  7. vamp_cli/agent/roles.py +8 -0
  8. vamp_cli/agent/runtime/__init__.py +0 -0
  9. vamp_cli/agent/runtime/runtime.py +133 -0
  10. vamp_cli/agent/state.py +55 -0
  11. vamp_cli/agent/status.py +17 -0
  12. vamp_cli/config/__init__.py +0 -0
  13. vamp_cli/config/loader.py +152 -0
  14. vamp_cli/config/schema.py +82 -0
  15. vamp_cli/llm/__init__.py +0 -0
  16. vamp_cli/llm/adapter.py +52 -0
  17. vamp_cli/llm/adapter_registry.py +26 -0
  18. vamp_cli/llm/debug_callbacks.py +18 -0
  19. vamp_cli/llm/router.py +247 -0
  20. vamp_cli/llm/usage.py +170 -0
  21. vamp_cli/logging_setup/logging_setup.py +37 -0
  22. vamp_cli/main.py +1023 -0
  23. vamp_cli/persistence/__init__.py +29 -0
  24. vamp_cli/persistence/compaction.py +372 -0
  25. vamp_cli/persistence/emitter.py +371 -0
  26. vamp_cli/persistence/event_log.py +303 -0
  27. vamp_cli/persistence/fork.py +81 -0
  28. vamp_cli/persistence/loader.py +616 -0
  29. vamp_cli/persistence/transcript.py +160 -0
  30. vamp_cli/prompts/prompts.py +182 -0
  31. vamp_cli/sandbox/__init__.py +0 -0
  32. vamp_cli/sandbox/base.py +22 -0
  33. vamp_cli/sandbox/config.py +16 -0
  34. vamp_cli/sandbox/docker.py +293 -0
  35. vamp_cli/sandbox/manager.py +31 -0
  36. vamp_cli/sandbox/result.py +14 -0
  37. vamp_cli/sandbox/terminal.py +151 -0
  38. vamp_cli/tools/__init__.py +0 -0
  39. vamp_cli/tools/base.py +44 -0
  40. vamp_cli/tools/diagnostic.py +120 -0
  41. vamp_cli/tools/execute_cmd.py +16 -0
  42. vamp_cli/tools/files.py +652 -0
  43. vamp_cli/tools/registry.py +163 -0
  44. vamp_cli/tools/tools_classes.py +622 -0
  45. vamp_cli/utils/__init__.py +0 -0
  46. vamp_cli/utils/error_codes.py +7 -0
  47. vamp_cli/utils/ignore.py +36 -0
  48. vamp_cli/utils/sha_256.py +32 -0
  49. vamp_cli/utils/token_tracker.py +31 -0
  50. vamp_cli/workspace/__init__.py +0 -0
  51. vamp_cli/workspace/workspace.py +16 -0
  52. vamp_cli-0.1.0.dist-info/METADATA +317 -0
  53. vamp_cli-0.1.0.dist-info/RECORD +57 -0
  54. vamp_cli-0.1.0.dist-info/WHEEL +5 -0
  55. vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
  56. vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. vamp_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,29 @@
1
+ from vamp_cli.persistence.event_log import EventLog, uuid7, iter_events
2
+ from vamp_cli.persistence.emitter import EventEmitter
3
+ from vamp_cli.persistence.loader import (
4
+ ResumedSession, load_session, load_chain, load_all_events, list_sessions,
5
+ derive_display_state, detect_pending_interrupt,
6
+ reconstruct_agent_state, current_thread_id,
7
+ list_user_messages, list_leaves, build_shadow_map,
8
+ _repair_orphaned_tool_calls
9
+ )
10
+ from vamp_cli.persistence.fork import fork_at_event
11
+ from vamp_cli.persistence.compaction import (
12
+ compact_session, plan_compaction_range,
13
+ build_summary_transcript, DEFAULT_KEEP_TURNS, auto_compact_if_needed, DEFAULT_COMPACT_THRESHOLD
14
+ )
15
+ from vamp_cli.persistence.transcript import render_transcript, print_transcript
16
+
17
+ __all__ = [
18
+ "EventLog", "EventEmitter", "uuid7", "iter_events",
19
+ "ResumedSession", "load_session", "load_chain", "load_all_events",
20
+ "list_sessions", "derive_display_state", "detect_pending_interrupt",
21
+ "reconstruct_agent_state", "current_thread_id",
22
+ "list_user_messages", "list_leaves", "build_shadow_map",
23
+ "fork_at_event",
24
+ "compact_session", "plan_compaction_range",
25
+ "build_summary_transcript", "DEFAULT_KEEP_TURNS",
26
+ "render_transcript", "print_transcript",
27
+ "auto_compact_if_needed", "DEFAULT_COMPACT_THRESHOLD",
28
+ "_repair_orphaned_tool_calls"
29
+ ]
@@ -0,0 +1,372 @@
1
+ """
2
+ Compaction: summarize a middle range of the current branch into a single
3
+ `compaction` event. Nothing is deleted; the range is shadowed on read.
4
+
5
+ Only one compaction is expected to be "live" at a time: each new
6
+ compaction's range starts at the first post-prompt user message and ends
7
+ just before the keep boundary, so it naturally subsumes prior
8
+ compactions that fall inside the range.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ from langchain_core.messages import HumanMessage, RemoveMessage, SystemMessage
17
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES
18
+ from langchain_core.runnables import RunnableConfig
19
+
20
+ from vamp_cli.persistence.event_log import EventLog
21
+ from vamp_cli.persistence.loader import (
22
+ reconstruct_agent_state, build_shadow_map,
23
+ )
24
+
25
+ DEFAULT_KEEP_TURNS = 4
26
+
27
+
28
+
29
+ # Range planning
30
+ def plan_compaction_range(
31
+ chain: list[dict],
32
+ *,
33
+ keep_turns: int = DEFAULT_KEEP_TURNS,
34
+ ) -> dict[str, Any] | None:
35
+ """
36
+ Decide which contiguous slice of the chain to summarize.
37
+
38
+ Returns None if there is nothing compactable.
39
+ """
40
+ user_events = [e for e in chain if e.get("type") == "user_message"]
41
+ if len(user_events) <= keep_turns:
42
+ return None
43
+
44
+ first_keep_index = len(user_events) - keep_turns
45
+ first_to_compact = user_events[0]
46
+ boundary_user = user_events[first_keep_index]
47
+
48
+ start_uuid = first_to_compact["uuid"]
49
+ end_uuid = boundary_user["parentUuid"]
50
+ if not end_uuid:
51
+ return None
52
+
53
+ # Collect uuids in chain order between start and end (inclusive).
54
+ collecting = False
55
+ range_uuids: list[str] = []
56
+ for e in chain:
57
+ u = e.get("uuid")
58
+ if u == start_uuid:
59
+ collecting = True
60
+ if collecting and u:
61
+ range_uuids.append(u)
62
+ if u == end_uuid:
63
+ break
64
+
65
+ if not range_uuids:
66
+ return None
67
+
68
+ range_set = set(range_uuids)
69
+ range_events = [e for e in chain if e.get("uuid") in range_set]
70
+ shadowed, _ = build_shadow_map(range_events)
71
+
72
+ # Approximate token cost of the visible (non-shadowed) events.
73
+ est_chars = 0
74
+ for e in range_events:
75
+ if e.get("uuid") in shadowed:
76
+ continue
77
+ d = e.get("data") or {}
78
+ est_chars += len(d.get("content") or "")
79
+ for tc in d.get("tool_calls") or []:
80
+ est_chars += len(json.dumps(tc.get("args") or {}))
81
+ est_tokens = max(1, est_chars // 4)
82
+
83
+ return {
84
+ "start_uuid": start_uuid,
85
+ "end_uuid": end_uuid,
86
+ "range_uuids": range_uuids,
87
+ "event_count": len(range_events),
88
+ "est_tokens": est_tokens,
89
+ }
90
+
91
+
92
+
93
+ # Summarization input + call
94
+ def build_summary_transcript(chain: list[dict], range_uuids: list[str]) -> str:
95
+ """Render the range as a compact, LLM-friendly transcript."""
96
+ range_set = set(range_uuids)
97
+ lines: list[str] = []
98
+ for e in chain:
99
+ if e.get("uuid") not in range_set:
100
+ continue
101
+ t = e.get("type")
102
+ d = e.get("data") or {}
103
+ if t == "user_message":
104
+ lines.append(f"USER: {d.get('content', '').strip()}")
105
+ elif t == "assistant_message":
106
+ content = (d.get("content") or "").strip()
107
+ if content:
108
+ lines.append(f"ASSISTANT: {content}")
109
+ for tc in d.get("tool_calls") or []:
110
+ args = json.dumps(tc.get("args") or {}, separators=(",", ":"))[:300]
111
+ lines.append(f" [tool_call {tc.get('name')} {args}]")
112
+ # elif t == "tool_result":
113
+ # content = (d.get("content") or "")[:500].replace("\n", " ")
114
+ # lines.append(
115
+ # f" [tool_result {d.get('tool_name')} "
116
+ # f"status={d.get('status')}] {content}"
117
+ # )
118
+ elif t == "mode_switch":
119
+ lines.append(f"[mode: {d.get('from')} -> {d.get('to')}]")
120
+ elif t == "interrupt_request":
121
+ lines.append(f"[interrupt requested: {d.get('kind')}]")
122
+ elif t == "interrupt_response":
123
+ verdict = "approved" if d.get("approved") else "denied"
124
+ lines.append(f"[user {verdict} {d.get('kind')}]")
125
+ elif t == "compaction":
126
+ # prior compaction inside this range: include its summary
127
+ summary = (d.get("summary") or "").strip()
128
+ if summary:
129
+ lines.append(f"[earlier summary] {summary}")
130
+ return "\n".join(lines)
131
+
132
+
133
+ _SUMMARY_PROMPT = """\
134
+ You are a conversation summarizer for an AI coding assistant session.
135
+
136
+ Read the transcript and produce a summary that another AI coding agent
137
+ will use in place of the original messages. Capture:
138
+
139
+ - What the user asked for and what was accomplished
140
+ - Key decisions and concrete code changes (files, functions touched)
141
+ - Technologies, constraints, and any user preferences expressed
142
+ - Current state and open questions / next steps
143
+
144
+ Be concise but information-dense. Use short bullet points grouped by
145
+ topic. Do not editorialize, do not add interpretation. Preserve any
146
+ concrete identifiers (file paths, function names, command names,
147
+ error strings) verbatim.
148
+ """
149
+
150
+
151
+ async def summarize_transcript(model, transcript: str) -> str:
152
+ msgs = [
153
+ SystemMessage(content=_SUMMARY_PROMPT),
154
+ HumanMessage(content=f"TRANSCRIPT:\n\n{transcript}\n\nSUMMARY:"),
155
+ ]
156
+ response = await model.ainvoke(msgs)
157
+ content = response.content
158
+ return content if isinstance(content, str) else str(content)
159
+
160
+
161
+ # Checkpointer refresh
162
+ async def apply_compaction_to_checkpointer(
163
+ graph,
164
+ session_config: dict | RunnableConfig,
165
+ new_state: dict,
166
+ ) -> None:
167
+ """
168
+ Replace the message channels on the checkpointer with the compacted
169
+ view. Uses REMOVE_ALL_MESSAGES to override add_messages' append
170
+ reducer, so we end up with exactly the new lists.
171
+ """
172
+ updates = {
173
+ "coder_messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)] + list(new_state["coder_messages"]),
174
+ "planner_messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)] + list(new_state["planner_messages"]),
175
+ "plan": new_state.get("plan"),
176
+ "todo": new_state.get("todo") or [],
177
+ }
178
+ await graph.aupdate_state(session_config, updates)
179
+
180
+
181
+ # Public entry
182
+ async def compact_session(
183
+ log: EventLog,
184
+ *,
185
+ chain: list[dict],
186
+ graph,
187
+ session_config: dict | RunnableConfig,
188
+ model,
189
+ keep_turns: int = DEFAULT_KEEP_TURNS,
190
+ channel: str = "both",
191
+ ) -> dict[str, Any]:
192
+ """
193
+ Compact the middle of the current branch.
194
+
195
+ Returns a result dict; raises ValueError if there is nothing to do.
196
+ """
197
+ plan = plan_compaction_range(chain, keep_turns=keep_turns)
198
+ if plan is None:
199
+ raise ValueError(
200
+ f"nothing to compact (need > {keep_turns} user turns)"
201
+ )
202
+
203
+ transcript = build_summary_transcript(chain, plan["range_uuids"])
204
+ if not transcript.strip():
205
+ raise ValueError("compaction range produced empty transcript")
206
+
207
+ summary = await summarize_transcript(model, transcript)
208
+ if not summary.strip():
209
+ raise ValueError("summarizer returned empty summary")
210
+
211
+ est_after = max(1, len(summary) // 4)
212
+
213
+ await log.append("compaction", {
214
+ "range_uuids": plan["range_uuids"],
215
+ "start_uuid": plan["start_uuid"],
216
+ "end_uuid": plan["end_uuid"],
217
+ "channel": channel,
218
+ "summary": summary,
219
+ "event_count": plan["event_count"],
220
+ "tokens_before": plan["est_tokens"],
221
+ "tokens_after": est_after,
222
+ })
223
+
224
+ # Rebuild state from the updated chain (now shadowed) and push it
225
+ # into the checkpointer so the next turn sees the compacted view.
226
+ updated_chain = chain + [{
227
+ "type": "compaction",
228
+ "uuid": log.head_uuid,
229
+ "parentUuid": chain[-1].get("uuid") if chain else None,
230
+ "data": {
231
+ "range_uuids": plan["range_uuids"],
232
+ "summary": summary,
233
+ "channel": channel,
234
+ },
235
+ }]
236
+ start_ev = next(
237
+ (e for e in chain if e.get("type") == "session_start"), None
238
+ )
239
+ prompts = (start_ev or {}).get("data", {}).get("system_prompts")
240
+ new_state = reconstruct_agent_state(updated_chain, system_prompts=prompts)
241
+
242
+ await apply_compaction_to_checkpointer(graph, session_config, new_state)
243
+
244
+ return {
245
+ "event_count": plan["event_count"],
246
+ "tokens_before": plan["est_tokens"],
247
+ "tokens_after": est_after,
248
+ "summary": summary,
249
+ "new_state": new_state,
250
+ }
251
+
252
+
253
+
254
+ # Auto-compaction threshold
255
+ DEFAULT_COMPACT_THRESHOLD = 0.90
256
+
257
+
258
+ def get_model_context_window(model) -> int:
259
+ """
260
+ Best-effort context window for a LangChain chat model.
261
+
262
+ Order of preference:
263
+ 1. model.profile['max_input_tokens'] (langchain-model-profiles)
264
+ 2. model.profile['context_window']
265
+ 3. model.get_context_window() (newer LC)
266
+ 4. hardcoded fallback by model name
267
+ """
268
+ profile = getattr(model, "profile", None)
269
+ if isinstance(profile, dict):
270
+ for key in ("max_input_tokens", "context_window", "max_tokens"):
271
+ val = profile.get(key)
272
+ if isinstance(val, int) and val > 0:
273
+ return val
274
+
275
+ getter = getattr(model, "get_context_window", None)
276
+ if callable(getter):
277
+ try:
278
+ val = getter()
279
+ if isinstance(val, int) and val > 0:
280
+ return val
281
+ except Exception:
282
+ pass
283
+
284
+ name = (
285
+ getattr(model, "model_name", None)
286
+ or getattr(model, "model", None)
287
+ or ""
288
+ ).lower()
289
+
290
+ fallbacks = {
291
+ "gpt-4o": 128_000,
292
+ "gpt-4o-mini": 128_000,
293
+ "gpt-4-turbo": 128_000,
294
+ "gpt-4": 8_192,
295
+ "gpt-3.5": 4_096,
296
+ "claude-3-5-sonnet": 200_000,
297
+ "claude-3-opus": 200_000,
298
+ "claude-3-haiku": 200_000,
299
+ "gemini-1.5-pro": 1_048_576,
300
+ "gemini-1.5-flash":1_048_576,
301
+ "deepseek": 64_000,
302
+ }
303
+ for prefix, size in fallbacks.items():
304
+ if prefix in name:
305
+ return size
306
+
307
+ return 128_000 # default
308
+
309
+
310
+ def should_compact(
311
+ last_input_tokens: int,
312
+ context_window: int,
313
+ threshold: float = DEFAULT_COMPACT_THRESHOLD,
314
+ ) -> bool:
315
+ if context_window <= 0:
316
+ return False
317
+ return last_input_tokens / context_window >= threshold
318
+
319
+
320
+ async def auto_compact_if_needed(
321
+ log: EventLog,
322
+ *,
323
+ chain: list[dict],
324
+ graph,
325
+ session_config: dict | RunnableConfig,
326
+ model,
327
+ last_input_tokens: int,
328
+ context_window: int | None = None,
329
+ threshold: float = DEFAULT_COMPACT_THRESHOLD,
330
+ keep_turns: int = DEFAULT_KEEP_TURNS,
331
+ channel: str = "both",
332
+ ) -> dict | None:
333
+ """
334
+ Run compaction if the last prompt is at/above the threshold.
335
+
336
+ Returns the compaction result dict, or None if no compaction was run.
337
+ Call this synchronously from the turn boundary, before invoking the graph.
338
+ """
339
+ context_window = context_window or get_model_context_window(model)
340
+
341
+ if not should_compact(last_input_tokens, context_window, threshold):
342
+ return None
343
+
344
+ print(
345
+ f"\n[auto-compact] context at "
346
+ f"{last_input_tokens:,}/{context_window:,} tokens "
347
+ f"({last_input_tokens / context_window:.1%}) - summarizing..."
348
+ )
349
+
350
+ try:
351
+ result = await compact_session(
352
+ log,
353
+ chain=chain,
354
+ graph=graph,
355
+ session_config=session_config,
356
+ model=model,
357
+ keep_turns=keep_turns,
358
+ channel=channel,
359
+ )
360
+ except ValueError as e:
361
+ # Nothing to compact
362
+ print(f"[auto-compact] skipped: {e}")
363
+ return None
364
+ except Exception as e:
365
+ print(f"[auto-compact] failed: {e!r}")
366
+ return None
367
+
368
+ print(
369
+ f"[auto-compact] done: {result['event_count']} events, "
370
+ f"~{result['tokens_before']:,} → ~{result['tokens_after']:,} tokens\n"
371
+ )
372
+ return result