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
vamp_cli/main.py ADDED
@@ -0,0 +1,1023 @@
1
+ import sys
2
+ import asyncio
3
+ from pathlib import Path
4
+ from typing import Any
5
+ import click
6
+ import yaml
7
+ from prompt_toolkit import PromptSession, print_formatted_text
8
+ from prompt_toolkit.formatted_text import HTML, FormattedText
9
+ from prompt_toolkit.patch_stdout import patch_stdout
10
+
11
+ from vamp_cli.workspace.workspace import Workspace
12
+ from vamp_cli.config.loader import load_config
13
+ from vamp_cli.prompts.prompts import PromptStore
14
+
15
+ from vamp_cli.utils.ignore import IgnoreMatcher
16
+ from vamp_cli.utils.token_tracker import Token_Tracker
17
+
18
+ from vamp_cli.sandbox.terminal import Terminal
19
+ from vamp_cli.sandbox.docker import SandboxConfig
20
+ from vamp_cli.sandbox.manager import SandboxManager
21
+
22
+ from vamp_cli.tools.files import FileTools
23
+ from vamp_cli.tools.diagnostic import DiagnosticTools
24
+ from vamp_cli.tools.execute_cmd import ExecuteCommandTool
25
+ from vamp_cli.tools.tools_classes import *
26
+ from vamp_cli.tools.registry import ToolRegistry
27
+
28
+ from vamp_cli.agent.runtime.runtime import AgentRuntime
29
+ from vamp_cli.agent.graph import create_graph
30
+ from vamp_cli.agent.roles import AgentRole
31
+ from vamp_cli.agent.status import FeedbackResponse
32
+ from vamp_cli.agent.context_sync import mode_switch_sync
33
+
34
+ from vamp_cli.persistence.emitter import EventEmitter
35
+ from vamp_cli.persistence.event_log import EventLog, uuid7
36
+ from vamp_cli.persistence.fork import fork_at_event
37
+ from vamp_cli.persistence.transcript import print_transcript
38
+ from vamp_cli.persistence.compaction import compact_session, DEFAULT_KEEP_TURNS, plan_compaction_range, auto_compact_if_needed, DEFAULT_COMPACT_THRESHOLD
39
+ from vamp_cli.persistence.loader import (
40
+ load_session,
41
+ list_sessions,
42
+ ResumedSession,
43
+ load_chain,
44
+ list_user_messages,
45
+ list_leaves,
46
+ current_thread_id,
47
+ reconstruct_agent_state,
48
+ derive_display_state,
49
+ load_all_events,
50
+ _repair_orphaned_tool_calls
51
+ )
52
+
53
+ from vamp_cli.logging_setup.logging_setup import setup
54
+
55
+ from langgraph.types import Command
56
+ from langchain_core.runnables import RunnableConfig
57
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
58
+ from langchain_core.messages import HumanMessage, SystemMessage
59
+
60
+
61
+ __VERSION__ = "0.1.0"
62
+
63
+ # log = logging.getLogger(__name__)
64
+
65
+ PROJECT_DIR = str(Path.cwd())
66
+
67
+
68
+ def create(event_type: str, data: dict[str, Any]) -> str:
69
+ """Serializes events into structured strings"""
70
+ return json.dumps({"type": event_type, **data})
71
+
72
+
73
+ async def build_startup():
74
+
75
+ # --- services ---------------------------------------------------------
76
+
77
+ workspace = Workspace(root=PROJECT_DIR)
78
+ ignore = IgnoreMatcher(repo_root=str(workspace.root))
79
+ diagnostic = DiagnosticTools(workspace=workspace)
80
+ sandbox_config = SandboxConfig()
81
+ # d_sandbox = DockerSandbox(workspace, sandbox_config)
82
+ d_sandbox = SandboxManager(sandbox_config)
83
+
84
+ try:
85
+ # d_sandbox.start()
86
+ sandbox = await d_sandbox.create(workspace)
87
+ except Exception as e:
88
+ print(f"\n\nDocker not running. Please start docker.\n\n")
89
+ sys.exit(0)
90
+
91
+ terminal = Terminal(sandbox=sandbox)
92
+
93
+ file_tools = FileTools(
94
+ workspace=workspace,
95
+ ignore=ignore,
96
+ diagnostic=diagnostic,
97
+ )
98
+ exec_tool = ExecuteCommandTool(terminal=terminal)
99
+
100
+ # --- tool wrappers ----------------------------------------------------
101
+ tools = [
102
+ ListDirTool(file_tools),
103
+ ReadFileTool(file_tools),
104
+ ReadManyFilesTool(file_tools),
105
+ WriteFileTool(file_tools),
106
+ ApplySearchReplaceTool(file_tools),
107
+ FileSearchTool(file_tools),
108
+ RipgrepSearchTool(file_tools),
109
+ ExecuteCommandToolDef(exec_tool),
110
+ SubmitPlanTool(),
111
+ WriteTodosTool(),
112
+ ]
113
+
114
+ # --- tool registry ---------------------------------------------------------
115
+ registry = ToolRegistry()
116
+ registry.register_many(tools)
117
+
118
+ # log.info("Registered %d tools: %s", len(registry), registry.list_tool_names())
119
+
120
+ return registry, d_sandbox
121
+
122
+
123
+ async def create_runtime():
124
+
125
+ from vamp_cli.llm.router import ModelRouter
126
+ from vamp_cli.llm.adapter_registry import AdapterRegistry
127
+
128
+ config = load_config()
129
+
130
+ adapter = AdapterRegistry()
131
+
132
+ tool_registry, sandbox = await build_startup()
133
+
134
+ prompt_store = PromptStore(hot_reload=True)
135
+
136
+
137
+ model_router = ModelRouter(config=config, adapters=adapter)
138
+
139
+ return (
140
+ AgentRuntime(
141
+ config=config,
142
+ model_router=model_router,
143
+ tool_registry=tool_registry,
144
+ prompt_store=prompt_store,
145
+ ),
146
+ tool_registry,
147
+ sandbox,
148
+ )
149
+
150
+
151
+ async def streaming(
152
+ graph,
153
+ payload: dict | Command,
154
+ session_config: RunnableConfig,
155
+ emitter: EventEmitter,
156
+ ) -> Any:
157
+
158
+ call_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
159
+
160
+ header_printed = False
161
+ thinking_open = False
162
+
163
+ if isinstance(payload, dict):
164
+ print_formatted_text(
165
+ FormattedText([(
166
+ "dim",
167
+ F"\nMode: {payload["mode"]}"
168
+ )]),
169
+ end="\n"
170
+ )
171
+
172
+ try:
173
+ async for event in graph.astream_events(
174
+ payload, config=session_config, version="v2"
175
+ ):
176
+ await emitter.consume(event)
177
+
178
+ kind = event["event"]
179
+
180
+ if kind == "on_chat_model_start":
181
+ # First time we know a model will run, print the header.
182
+ if not header_printed:
183
+ print_formatted_text(
184
+ HTML("<b><ansigreen>\n\nAgent:</ansigreen></b> "),
185
+ end=""
186
+ )
187
+ header_printed = True
188
+
189
+ # capture chat model stream for final output and reasoning
190
+ elif kind == "on_chat_model_stream":
191
+ chunk = event["data"]["chunk"]
192
+
193
+ # reasoning chunks
194
+ reasoning_chunk = None
195
+ if hasattr(chunk, "reasoning_content") and chunk.reasoning_content:
196
+ reasoning_chunk = chunk.reasoning_content
197
+ elif "reasoning_content" in chunk.additional_kwargs:
198
+ reasoning_chunk = chunk.additional_kwargs["reasoning_content"]
199
+
200
+ if reasoning_chunk:
201
+ if not thinking_open:
202
+ # print("Thinking:\n", end="", flush=True)
203
+ print_formatted_text(
204
+ FormattedText([(
205
+ "dim",
206
+ f"\nThinking:\n"
207
+ )]),
208
+ end=""
209
+ )
210
+ thinking_open = True
211
+ # print(reasoning_chunk, end="", flush=True)
212
+ print_formatted_text(
213
+ FormattedText([(
214
+ "dim",
215
+ reasoning_chunk
216
+ )]),
217
+ end=""
218
+ )
219
+
220
+ # 2. capture final human text
221
+ elif chunk.content:
222
+ if thinking_open:
223
+ print("\n\n", end="", flush=True)
224
+ thinking_open = False
225
+ print(chunk.content, end="", flush=True)
226
+
227
+ elif kind == "on_chat_model_end":
228
+ output = event["data"].get("output")
229
+ um = getattr(output, "usage_metadata", None)
230
+ if um:
231
+ call_usage["input_tokens"] += um.get("input_tokens", 0)
232
+ call_usage["output_tokens"] += um.get("output_tokens", 0)
233
+ call_usage["total_tokens"] += um.get("total_tokens", 0)
234
+
235
+ except (asyncio.CancelledError, KeyboardInterrupt) as e:
236
+ print("\n[aborted by user]")
237
+ return call_usage
238
+ except Exception as e:
239
+ print("\n[Some error happened]: ", e)
240
+ return call_usage
241
+
242
+ print("\n")
243
+ return call_usage
244
+
245
+
246
+ def edit_config(path: str | Path) -> str | None:
247
+ path = Path(path)
248
+ text = path.read_text(encoding="utf-8")
249
+
250
+ while True:
251
+ try:
252
+ edited = click.edit(text, extension=".yaml") # blocks until editor closes
253
+ if edited is None: # user quit without saving
254
+ return None
255
+
256
+ try:
257
+ data = yaml.safe_load(edited)
258
+ except yaml.YAMLError as e:
259
+ click.echo(f"Invalid YAML:\n{e}")
260
+ if not click.confirm("Re-open editor?", default=True):
261
+ return None
262
+ text = edited # keep changes so can be fixed
263
+ continue
264
+
265
+ path.write_text(edited, encoding="utf-8") # only save once valid
266
+ return "ok"
267
+
268
+ except click.ClickException as e:
269
+ raise
270
+
271
+ async def main(resume_id: str | None = None):
272
+
273
+ # logger
274
+ # setup(level=logging.DEBUG, log_dir=f"{PROJECT_DIR}/logs")
275
+
276
+ # basic
277
+ token_tracker = Token_Tracker()
278
+ agent_runtime, tool_registry, sandbox = await create_runtime()
279
+ coder_profile = agent_runtime.resolve(AgentRole.CODER)
280
+ planner_profile = agent_runtime.resolve(AgentRole.PLANNER)
281
+ VERSION = __VERSION__
282
+
283
+ # session
284
+ is_resume = resume_id is not None
285
+ SESSION_ID = resume_id if is_resume else str(uuid7())
286
+
287
+ sessions_dir = Path.home() / ".vamp" / "sessions"
288
+ checkpoint_dir = Path.home() / ".vamp" / "checkpoints"
289
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
290
+ checkpoint_db = checkpoint_dir / f"{SESSION_ID}.sqlite"
291
+
292
+ async with AsyncSqliteSaver.from_conn_string(str(checkpoint_db)) as checkpointer:
293
+ graph = create_graph(
294
+ coder_model=coder_profile.model,
295
+ planner_model=planner_profile.model,
296
+ tool_registry=tool_registry,
297
+ checkpointer=checkpointer
298
+ )
299
+
300
+
301
+ log = await EventLog.open(
302
+ session_id=SESSION_ID ,
303
+ workspace_root=PROJECT_DIR,
304
+ version=VERSION,
305
+ )
306
+ emitter = EventEmitter(log)
307
+
308
+ THREAD_ID = SESSION_ID
309
+
310
+ session_config: RunnableConfig = {
311
+ "configurable": {
312
+ "thread_id": THREAD_ID,
313
+ "emitter": emitter,
314
+ },
315
+ "recursion_limit": 200,
316
+ }
317
+
318
+ # fresh session
319
+ if not is_resume:
320
+ await log.append("session_start", {
321
+ "workspace_root": PROJECT_DIR,
322
+ "models": {
323
+ "coder": getattr(coder_profile.model, "model_name", None)
324
+ or getattr(coder_profile.model, "model", None),
325
+ "planner": getattr(planner_profile.model, "model_name", None)
326
+ or getattr(planner_profile.model, "model", None),
327
+ },
328
+ "roles": {"coder": AgentRole.CODER.value, "planner": AgentRole.PLANNER.value},
329
+ "tools": tool_registry.list_tool_names(),
330
+ "checkpoint_thread_id": THREAD_ID, # step 4 will make this per-session
331
+ "checkpoint_backend": "sqlite",
332
+ "system_prompts": {
333
+ "coder": coder_profile.system_prompt,
334
+ "planner": planner_profile.system_prompt,
335
+ },
336
+ })
337
+
338
+ # default loop state
339
+ is_session_started = False
340
+ pending_command = None
341
+ last_state = None
342
+ selected_mode: str = "normal" # user's current choice (changedd by '/mode')
343
+ executed_mode: str = "normal" # mode the last graph run used
344
+
345
+ # resume path
346
+ resumed: ResumedSession | None = None
347
+ if is_resume:
348
+ try:
349
+ resumed = await load_session(
350
+ sessions_dir=sessions_dir,
351
+ session_id=SESSION_ID,
352
+ graph=graph,
353
+ session_config=session_config,
354
+ reconstruct_if_missing=True,
355
+ )
356
+ except FileNotFoundError as e:
357
+ await sandbox.destroy()
358
+ print(f"[resume error] {e}")
359
+ return
360
+
361
+ selected_mode = resumed.selected_mode
362
+ executed_mode = resumed.executed_mode
363
+ is_session_started = resumed.is_session_started
364
+ last_state = resumed.last_state
365
+
366
+ if resumed.thread_id and resumed.thread_id != THREAD_ID:
367
+ THREAD_ID = resumed.thread_id
368
+ session_config["configurable"]["thread_id"] = THREAD_ID
369
+ print(f"[resume] using forked thread: {THREAD_ID}")
370
+
371
+ # banner
372
+ session = PromptSession()
373
+
374
+ print_formatted_text(
375
+ FormattedText([(
376
+ "yellow",
377
+ f"\nVAMP-CLI"
378
+ )]),
379
+ end="\n"
380
+ )
381
+ print_formatted_text(
382
+ FormattedText([(
383
+ "yellow",
384
+ f"\nSession: {SESSION_ID}" + (" (resumed)" if is_resume else "")
385
+ )]),
386
+ end="\n"
387
+ )
388
+ print_formatted_text(
389
+ FormattedText([(
390
+ "yellow",
391
+ "\nCommands:\n-- /mode: select normal or plan mode.\n-- /exit or /quit: exit cli\n-- /fork: fork a session.\n-- /branches: view branches of session.\n-- /compat: conversation compaction.\n-- /config: change model and profile configurations.\n\n"
392
+ )]),
393
+ end="\n"
394
+ )
395
+
396
+
397
+ if resumed:
398
+ # print(f"events loaded: {len(resumed.events)}")
399
+ # print(f"checkpointer: {resumed.checkpoint_source}")
400
+ # print(f"selected mode: {resumed.selected_mode}")
401
+ # print(f"executed mode: {resumed.executed_mode}\n")
402
+ for w in resumed.warnings:
403
+ print(f"\n [warn] {w}\n")
404
+
405
+ print_transcript(resumed.events)
406
+ print("\n-- End of history --\n")
407
+
408
+
409
+
410
+ # resume-parked-interrupt: run once before the loop
411
+ if resumed and resumed.pending_interrupt:
412
+ pi = resumed.pending_interrupt
413
+ checkpoint_data = pi.get("payload") or {}
414
+ interrupt_kind = pi.get("kind") or "unknown"
415
+
416
+ print("=" * 60)
417
+ print("RESUMED SESSION IS PARKED AT AN OUTSTANDING INTERRUPT")
418
+ print("=" * 60 + "\n")
419
+
420
+ if checkpoint_data.get("implementation_permission"):
421
+ print("PLAN APPROVAL REQUESTED")
422
+ print(f"\nPlan:\n\n{checkpoint_data.get('plan', '')}")
423
+ elif checkpoint_data.get("requires_tool_approval"):
424
+ print("SECURITY VERIFICATION INTERCEPTED")
425
+ calls = checkpoint_data.get("dangerous_calls", [])
426
+ print(f"\n{len(calls)} dangerous call(s) flagged; "
427
+ f"{checkpoint_data.get('safe_call_count', 0)} safe call(s) "
428
+ f"will also run on approval.\n")
429
+ for c in calls:
430
+ print(f" Tool: {c['tool_name']}")
431
+ print(f" Args: {json.dumps(c['tool_args'], indent=2)}\n")
432
+ print(f"System Message: {checkpoint_data.get('warning', '')}")
433
+ else:
434
+ print(f"Unrecognised interrupt payload:\n{checkpoint_data}")
435
+
436
+ print()
437
+ choice = (
438
+ (await session.prompt_async(
439
+ "Do you authorize the above ask? (yes/no/abort): "
440
+ ))
441
+ .strip().lower()
442
+ )
443
+
444
+ if choice in ("abort", "a", "quit", "exit"):
445
+ # Leave the interrupt unresolved but close cleanly.
446
+ await log.append("interrupt_response", {
447
+ "kind": interrupt_kind,
448
+ "approved": False,
449
+ "reason": "user aborted resume",
450
+ })
451
+ await log.close(reason="user_abort_resume")
452
+ await sandbox.destroy()
453
+ return
454
+
455
+ if choice in ("y", "yes"):
456
+ await log.append("interrupt_response", {
457
+ "kind": interrupt_kind,
458
+ "approved": True,
459
+ "reason": None,
460
+ })
461
+ pending_command = Command(
462
+ resume=FeedbackResponse(approved=True)
463
+ )
464
+ else:
465
+ reason = (await session.prompt_async("Reason: ")).strip()
466
+ await log.append("interrupt_response", {
467
+ "kind": interrupt_kind,
468
+ "approved": False,
469
+ "reason": reason,
470
+ })
471
+ pending_command = Command(
472
+ resume=FeedbackResponse(approved=False, reason=reason)
473
+ )
474
+
475
+
476
+ # main loop
477
+ while True:
478
+ try:
479
+ with patch_stdout():
480
+ # 0. user selects the mode:
481
+ if pending_command is None:
482
+ # 1. get the query from the user
483
+ user_input = (await session.prompt_async(
484
+ HTML("<b><ansicyan>\nYou:</ansicyan></b> ")
485
+ )).strip()
486
+ if not user_input:
487
+ continue
488
+
489
+ # /config
490
+ if user_input == "/config":
491
+
492
+ try:
493
+ # open ~/.vamp/config.yaml
494
+ config = edit_config(Path.home() / ".vamp/config.yaml")
495
+ if config is None:
496
+ print("\nNo changes made.")
497
+ continue
498
+ print("\nRestart 'vamp' to load changed configuration.")
499
+ continue
500
+
501
+ except yaml.YAMLError as e:
502
+ print(f"Invalid YAML:\n{e}")
503
+ continue
504
+
505
+ except click.ClickException as e:
506
+ click.echo(f"Could not open an editor: {e}")
507
+ click.echo("Set the EDITOR environment variable, e.g. EDITOR=nano")
508
+ continue
509
+
510
+ except Exception as e:
511
+ print("\nConfiguration changes failed.\n\n")
512
+ continue
513
+
514
+
515
+ # /branches
516
+ if user_input == "/branches":
517
+ chain = load_all_events(sessions_dir, SESSION_ID)
518
+ leaves = list_leaves(chain)
519
+ if not leaves:
520
+ print("No branches.")
521
+ continue
522
+
523
+ print(f"\n{len(leaves)} branch tip(s) in this session:\n")
524
+ for i, leaf in enumerate(leaves, 1):
525
+ leaf_chain = load_chain(sessions_dir, SESSION_ID, leaf_uuid=leaf)
526
+ last_user = next(
527
+ (e for e in reversed(leaf_chain)
528
+ if e.get("type") == "user_message"),
529
+ None,
530
+ )
531
+ preview = ""
532
+ if last_user:
533
+ preview = ((last_user.get("data") or {}).get("content") or "")[:60]
534
+ preview = preview.replace("\n", " ")
535
+ marker = " <- current" if leaf == log.head_uuid else ""
536
+ print(f" [{i}] {leaf[:8]} {preview}{marker}")
537
+
538
+ pick = (await session.prompt_async("Switch to branch [1-N] or 'cancel': ")).strip()
539
+ if pick.lower() in ("", "cancel", "c"):
540
+ continue
541
+ try:
542
+ idx = int(pick)
543
+ if idx < 1 or idx > len(leaves):
544
+ print("Out of range.")
545
+ continue
546
+ except ValueError:
547
+ print("Not a number.")
548
+ continue
549
+
550
+ target_leaf = leaves[idx - 1]
551
+
552
+ # Move the head; do NOT write a fork event - this is a checkout, not a fork.
553
+ await log.set_head(target_leaf)
554
+
555
+ # Rebuild state for the new branch.
556
+ chain = load_chain(sessions_dir, SESSION_ID, leaf_uuid=target_leaf)
557
+ new_state = reconstruct_agent_state(chain)
558
+ new_thread = current_thread_id(chain, default=SESSION_ID)
559
+
560
+ # Move to that branch's checkpointer thread.
561
+ THREAD_ID = new_thread
562
+ session_config["configurable"]["thread_id"] = THREAD_ID
563
+
564
+ # Refresh CLI-side state.
565
+ disp = derive_display_state(chain)
566
+ selected_mode = disp["selected_mode"]
567
+ executed_mode = disp["executed_mode"]
568
+ is_session_started = disp["is_session_started"]
569
+ last_state = new_state
570
+
571
+ print(f"Switched to branch {target_leaf}. "
572
+ f"thread={THREAD_ID} mode={selected_mode} "
573
+ f"messages={len(new_state['coder_messages'])}c/"
574
+ f"{len(new_state['planner_messages'])}p")
575
+ print_transcript(chain, header=f"-- Branch {target_leaf} --")
576
+ print("\n-- End of history --\n")
577
+ continue
578
+
579
+ # /fork
580
+ if user_input == "/fork" or user_input.startswith("/fork "):
581
+ chain = load_chain(sessions_dir, SESSION_ID)
582
+ ums = list_user_messages(chain)
583
+
584
+ if not ums:
585
+ print("No user messages to fork to.")
586
+ continue
587
+
588
+ shadowed_count = sum(1 for m in ums if m["shadowed"])
589
+ header = "\nUser messages on this branch"
590
+ if shadowed_count:
591
+ header += f" ({shadowed_count} compacted)"
592
+ print(header + ":\n")
593
+
594
+ for m in ums:
595
+ ts = (m["timestamp"] or "")[11:19]
596
+ preview = m["content"].replace("\n", " ")[:70]
597
+ tag = " [compacted]" if m["shadowed"] else ""
598
+ print(f" [{m['index']}] {ts} {preview}{tag}")
599
+
600
+ # footer explaining fork semantics for compacted branches
601
+ any_compaction = any(e.get("type") == "compaction" for e in chain)
602
+ if any_compaction:
603
+ print()
604
+ print("Note: rewinding creates a new branch from that point. Because the")
605
+ print("compaction event currently sits at the tip of this branch, any")
606
+ print("fork goes to a point BEFORE it - the new branch will show the")
607
+ print("original (uncompacted) messages from that point on.")
608
+
609
+ if user_input.startswith("/fork "):
610
+ pick = user_input[len("/fork "):].strip()
611
+ else:
612
+ pick = (await session.prompt_async(
613
+ "Rewind to BEFORE message [1-N] or 'cancel': "
614
+ )).strip()
615
+
616
+ if pick.lower() in ("", "cancel", "c"):
617
+ continue
618
+ try:
619
+ idx = int(pick)
620
+ except ValueError:
621
+ print("Not a number.")
622
+ continue
623
+ if idx < 1 or idx > len(ums):
624
+ print("Out of range.")
625
+ continue
626
+
627
+ target = ums[idx - 1]
628
+
629
+ if target["shadowed"]:
630
+ print(f"\nMessage [{idx}] has been compacted away.")
631
+ print("Rewinding to it will produce a new branch where that compaction")
632
+ print("does not apply - the original messages from that point will be")
633
+ print("restored on the new branch.")
634
+ confirm = (await session.prompt_async("Proceed? [y/N]: ")).strip().lower()
635
+ if confirm not in ("y", "yes"):
636
+ print("Cancelled.")
637
+ continue
638
+
639
+ fork_point = target["parent_uuid"]
640
+ if not fork_point:
641
+ print("Cannot fork before the first event.")
642
+ continue
643
+
644
+ print(f"\nRewinding before message [{idx}]: \"{target['content'][:60]}\"")
645
+ print("(The old branch is preserved; nothing is deleted.)\n")
646
+
647
+ try:
648
+ result = await fork_at_event(
649
+ log,
650
+ session_id=SESSION_ID,
651
+ fork_point_uuid=fork_point,
652
+ graph=graph,
653
+ reason=f"user_rewind_before_user_message_{idx}",
654
+ )
655
+ except Exception as e:
656
+ print(f"[fork failed] {e!r}")
657
+ continue
658
+
659
+ THREAD_ID = result["thread_id"]
660
+ session_config["configurable"]["thread_id"] = THREAD_ID
661
+ last_state = result["state"]
662
+
663
+ chain = load_chain(sessions_dir, SESSION_ID, leaf_uuid=result["fork_uuid"])
664
+ disp = derive_display_state(chain)
665
+ selected_mode = disp["selected_mode"]
666
+ executed_mode = disp["executed_mode"]
667
+ is_session_started = disp["is_session_started"]
668
+
669
+ print(f"Forked. new_thread={THREAD_ID}")
670
+ print(f" state: {len(last_state['coder_messages'])} coder / "
671
+ f"{len(last_state['planner_messages'])} planner messages, "
672
+ f"iteration={last_state.get('iteration', 0)}")
673
+ print("Next message you type starts the new branch.\n")
674
+
675
+ print_transcript(chain, header=f"-- Forked branch {result['fork_uuid'][:8]} --")
676
+ print("\n-- End of history --\n")
677
+ continue
678
+
679
+ # /compact
680
+ if user_input == "/compact" or user_input.startswith("/compact "):
681
+ # parse optional keep_turns
682
+ keep = DEFAULT_KEEP_TURNS
683
+ if user_input.startswith("/compact "):
684
+ try:
685
+ keep = int(user_input[len("/compact "):].strip())
686
+ if keep < 0:
687
+ raise ValueError
688
+ except ValueError:
689
+ print("Usage: /compact [keep_turns] (default 4)")
690
+ continue
691
+
692
+ chain = load_chain(sessions_dir, SESSION_ID)
693
+ plan = plan_compaction_range(chain, keep_turns=keep)
694
+ if plan is None:
695
+ print(f"Nothing to compact - you have ≤ {keep} user turns on this branch.")
696
+ continue
697
+
698
+ if pending_command is not None:
699
+ print("Cannot compact while resuming an interrupt. Resolve it first.")
700
+ continue
701
+
702
+ print(f"\nWill compact {plan['event_count']} events "
703
+ f"covering the earliest user turns up to (but not including) "
704
+ f"the last {keep} - roughly ~{plan['est_tokens']} tokens.")
705
+ print(f"Keeping the last {keep} user turns intact.")
706
+ confirm = (await session.prompt_async("Proceed? [Y/n]: ")).strip().lower()
707
+ if confirm not in ("", "y", "yes"):
708
+ print("Cancelled.")
709
+ continue
710
+
711
+ print("Summarizing...")
712
+ try:
713
+ result = await compact_session(
714
+ log,
715
+ chain=chain,
716
+ graph=graph,
717
+ session_config=session_config,
718
+ model=coder_profile.model if selected_mode == "normal" else planner_profile.model,
719
+ keep_turns=keep,
720
+ channel="both",
721
+ )
722
+ except ValueError as e:
723
+ print(f"[compact] {e}")
724
+ continue
725
+ except Exception as e:
726
+ print(f"[compact failed] {e!r}")
727
+ continue
728
+
729
+ last_state = result["new_state"]
730
+ print(f"\nCompacted {result['event_count']} events: "
731
+ f"~{result['tokens_before']} → ~{result['tokens_after']} tokens.")
732
+ print("Summary preview:\n")
733
+ preview = result["summary"].strip()
734
+ print(" " + preview.replace("\n", "\n "))
735
+ # if len(preview) > 600:
736
+ # print(" ...")
737
+ print()
738
+ continue
739
+
740
+ # /exit or /quit
741
+ if user_input.lower() in ["/exit", "/quit"]:
742
+ print("Stopping active workspace containers...")
743
+ await log.close(reason="user_exit")
744
+ await sandbox.destroy()
745
+ return
746
+
747
+ # /mode
748
+ if user_input == "/mode":
749
+ picked = (
750
+ (await session.prompt_async("Select Mode [plan/normal]: "))
751
+ .strip()
752
+ .lower()
753
+ )
754
+
755
+ if picked not in ["plan", "normal"]:
756
+ print("Type the correct mode spelling.")
757
+ print("Mode not selected. try again...")
758
+ continue
759
+
760
+ prev = selected_mode
761
+ selected_mode = picked
762
+
763
+ if prev != selected_mode:
764
+ await log.append("mode_switch", {
765
+ "from": prev,
766
+ "to": selected_mode,
767
+ })
768
+ emitter.last_input_tokens = 0
769
+ print(f"{selected_mode} mode selected. Continue...")
770
+ else:
771
+ print(f"Already in {selected_mode} mode.")
772
+ continue
773
+
774
+ token_tracker.start()
775
+ new_mode = selected_mode
776
+
777
+ # Persist the user's message before building payload.
778
+ await log.append("user_message", {
779
+ "content": user_input,
780
+ "mode": new_mode,
781
+ })
782
+
783
+ # auto-compaction gate
784
+ # Fires only when the previous turn's final prompt was
785
+ # at/above the threshold. Runs synchronously so the
786
+ # checkpointer and last_state are consistent before the
787
+ # graph is invoked.
788
+ chain = load_chain(sessions_dir, SESSION_ID)
789
+ auto = await auto_compact_if_needed(
790
+ log,
791
+ chain=chain,
792
+ graph=graph,
793
+ session_config=session_config,
794
+ model=coder_profile.model if new_mode == "normal" else planner_profile.model,
795
+ last_input_tokens=emitter.last_input_tokens,
796
+ context_window=coder_profile.context_window if new_mode == "normal" else planner_profile.context_window,
797
+ threshold=DEFAULT_COMPACT_THRESHOLD,
798
+ keep_turns=DEFAULT_KEEP_TURNS,
799
+ )
800
+ if auto is not None:
801
+ last_state = auto["new_state"]
802
+ # Reset the tracked input size after compaction so
803
+ # we don't retrigger on the next turn before the
804
+ # model has actually seen the new prompt size.
805
+ emitter.last_input_tokens = 0
806
+
807
+
808
+ # 2. Build or pass updates to the thread state
809
+ if not is_session_started:
810
+ # first message so initialize all seed states
811
+ payload = {
812
+ "planner_messages": [
813
+ SystemMessage(
814
+ content=planner_profile.system_prompt,
815
+ id="planner_system_prompt"
816
+ ),
817
+ HumanMessage(content=user_input),
818
+ ],
819
+ "coder_messages": [
820
+ SystemMessage(
821
+ content=coder_profile.system_prompt,
822
+ id="coder_system_prompt"
823
+ ),
824
+ HumanMessage(content=user_input),
825
+ ],
826
+ "task": user_input,
827
+ "mode": new_mode,
828
+ "plan": None,
829
+ "requires_execution": False,
830
+ "todo": [],
831
+ "iteration": 0,
832
+ "_pending_tool_channel": None,
833
+ "_pending_tool_calls": [],
834
+ "_dangerous_tool_call_ids": [],
835
+ "_approved_tool_call_ids": [],
836
+ "_denied_tool_call_ids": [],
837
+ }
838
+ is_session_started = True
839
+ else:
840
+ # recurring turns
841
+
842
+ # context sync on mode switch
843
+ sync = mode_switch_sync(
844
+ prev_mode=executed_mode,
845
+ new_mode=new_mode,
846
+ last_state=last_state,
847
+ )
848
+
849
+ message_type = (
850
+ "planner_messages"
851
+ if new_mode == "plan"
852
+ else "coder_messages"
853
+ )
854
+
855
+ payload = {
856
+ message_type: sync.get(message_type, [])
857
+ + [HumanMessage(content=user_input)],
858
+ "mode": new_mode,
859
+ }
860
+
861
+ # force a fresh plan when moving from normal -> plan
862
+ if new_mode == "plan":
863
+ payload["is_plan_injected"] = False
864
+
865
+ # dropping stale plan when moving from plan -> normal
866
+ if new_mode == "normal" and executed_mode == "plan":
867
+ payload["plan"] = ""
868
+
869
+ executed_mode = new_mode
870
+
871
+ else:
872
+ payload = pending_command
873
+ pending_command = None
874
+
875
+ # 2. Fire execution step
876
+ usage = await streaming(graph, payload, session_config, emitter)
877
+ token_tracker.add(usage)
878
+
879
+ # interrupt handling
880
+ # 3. Handle runtime verification interrupts
881
+ state_snapshot = await graph.aget_state(session_config)
882
+ result = state_snapshot.values
883
+ # cache full state for next switch
884
+ last_state = result
885
+
886
+ active_interrupts = getattr(state_snapshot, "interrupts", [])
887
+ if not active_interrupts and state_snapshot.tasks:
888
+ # check inside individual graph task state frames
889
+ for task in state_snapshot.tasks:
890
+ if getattr(task, "interrupts", None):
891
+ active_interrupts = task.interrupts
892
+ break
893
+
894
+ if active_interrupts:
895
+ raw_interrupt = active_interrupts[0]
896
+ checkpoint_data = getattr(raw_interrupt, "value", raw_interrupt)
897
+
898
+ # classify & emit interrupt_request
899
+
900
+ # plan approval
901
+ if checkpoint_data.get("implementation_permission"): # type: ignore
902
+ interrupt_kind = "plan_approval"
903
+ prompt_text = "Do you authorize the above plan? (yes/no): "
904
+
905
+ # tool approval
906
+ elif checkpoint_data.get("requires_tool_approval"): # type: ignore
907
+ interrupt_kind = "tool_approval"
908
+ prompt_text = "Do you authorize the above ask? (yes/no): "
909
+
910
+ else:
911
+ # unknown payload - surface raw for debugging
912
+ interrupt_kind = "unknown"
913
+ prompt_text = "Do you authorize the above ask? (yes/no): "
914
+
915
+ await log.append("interrupt_request", {
916
+ "kind": interrupt_kind,
917
+ "payload": checkpoint_data,
918
+ })
919
+
920
+ # display
921
+ print("\n" + "*" * 40)
922
+ if checkpoint_data.get("implementation_permission"): # type: ignore
923
+ print("PLAN APPROVAL REQUESTED")
924
+ print(f"\nPlan:\n\n{checkpoint_data.get('plan', '')}") # type: ignore
925
+
926
+ elif checkpoint_data.get("requires_tool_approval"): # type: ignore
927
+ print("SECURITY VERIFICATION INTERCEPTED")
928
+ calls = checkpoint_data.get("dangerous_calls", []) # type: ignore
929
+ print(
930
+ f"\n{len(calls)} dangerous call(s) flagged; "
931
+ f"{checkpoint_data.get('safe_call_count', 0)} safe call(s) will " # type: ignore
932
+ f"also run on approval.\n"
933
+ )
934
+ for c in calls:
935
+ print(f" Tool: {c['tool_name']}")
936
+ print(f" Args: {json.dumps(c['tool_args'], indent=2)}")
937
+ print()
938
+ print(f"System Message: {checkpoint_data.get('warning', '')}") # type: ignore
939
+
940
+ else:
941
+ print(f"Unrecognised interrupt payload:\n{checkpoint_data}")
942
+ print("*" * 40 + "\n")
943
+
944
+ # collect response
945
+ choice = (
946
+ (
947
+ await session.prompt_async(
948
+ "Do you authorize the above ask? (yes/no): "
949
+ )
950
+ )
951
+ .strip()
952
+ .lower()
953
+ )
954
+
955
+ if choice in ("y", "yes"):
956
+ print("Authorized. Continuing execution...")
957
+ await log.append("interrupt_response", {
958
+ "kind": interrupt_kind,
959
+ "approved": True,
960
+ "reason": None,
961
+ })
962
+ pending_command = Command(
963
+ resume=FeedbackResponse(approved=True)
964
+ )
965
+ continue
966
+ else:
967
+ reason = (await session.prompt_async("Reason: ")).strip()
968
+ await log.append("interrupt_response", {
969
+ "kind": interrupt_kind,
970
+ "approved": False,
971
+ "reason": reason,
972
+ })
973
+ print("Blocked. Returning denial to agent history...")
974
+ pending_command = Command(
975
+ resume=FeedbackResponse(approved=False, reason=reason)
976
+ )
977
+ continue
978
+
979
+ token_tracker.close()
980
+
981
+ except KeyboardInterrupt:
982
+ print("\n[interrupted - returning to prompt]")
983
+ pending_command = None
984
+ continue
985
+
986
+ except Exception as e:
987
+ await log.close(reason="error")
988
+ await sandbox.destroy()
989
+ print(f"[runtime error] {e}")
990
+ return
991
+
992
+ def start():
993
+
994
+ args = sys.argv[1:]
995
+ resume_id = None
996
+ list_flag = False
997
+
998
+ if "--list" in args or "-l" in args:
999
+ list_flag = True
1000
+ if "--resume" in args or "-r" in args:
1001
+ i = args.index("--resume" if "--resume" in args else "-r")
1002
+ if i + 1 < len(args):
1003
+ resume_id = args[i + 1]
1004
+
1005
+ if list_flag:
1006
+ sessions_dir = Path.home() / ".vamp" / "sessions"
1007
+ rows = list_sessions(sessions_dir)
1008
+ if not rows:
1009
+ print("No sessions found.")
1010
+ return
1011
+ print(f"\n{len(rows)} session(s) in {sessions_dir}:\n")
1012
+ for r in rows:
1013
+ started = (r["started_at"] or "")[:19]
1014
+ msg = (r["first_message"] or "")[:70].replace("\n", " ")
1015
+ print(f" {r['session_id']} {started} {msg}")
1016
+ print()
1017
+ return
1018
+
1019
+ asyncio.run(main(resume_id=resume_id))
1020
+
1021
+
1022
+ if __name__ == "__main__":
1023
+ start()