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
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()
|