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/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from langchain_core.messages import SystemMessage, BaseMessage
|
|
2
|
+
|
|
3
|
+
def mode_switch_sync(
|
|
4
|
+
prev_mode: str,
|
|
5
|
+
new_mode: str,
|
|
6
|
+
last_state = None,
|
|
7
|
+
) -> dict:
|
|
8
|
+
"""Return payload additions to inject when switching modes.
|
|
9
|
+
Returns {} if there is nothing to sync.
|
|
10
|
+
"""
|
|
11
|
+
if prev_mode == new_mode or last_state is None:
|
|
12
|
+
return {}
|
|
13
|
+
|
|
14
|
+
# plan -> normal : hand the plan to the coder as context
|
|
15
|
+
if prev_mode == "plan" and new_mode == "normal":
|
|
16
|
+
plan = last_state.get("plan") or "(no plan was produced)"
|
|
17
|
+
# todos = last_state.get("todo") or []
|
|
18
|
+
# todo_lines = "\n".join(
|
|
19
|
+
# f" [{t['status']}] {t['id']}. {t['description']}" for t in todos
|
|
20
|
+
# ) or " (empty)"
|
|
21
|
+
|
|
22
|
+
sync_msg = SystemMessage(
|
|
23
|
+
content=(
|
|
24
|
+
"MODE SWITCH: The user has switched you from PLAN mode to NORMAL mode.\n"
|
|
25
|
+
"You are no longer bound to a planner. You are free to act directly.\n"
|
|
26
|
+
"For your reference, this is the plan that was produced earlier:\n\n"
|
|
27
|
+
f"{plan}\n\n"
|
|
28
|
+
# "Current todo list at the moment of the switch:\n"
|
|
29
|
+
# f"{todo_lines}\n\n"
|
|
30
|
+
"Treat this as context only; do not re-inject the plan verbatim."
|
|
31
|
+
"NOTE: IF YOU HAVE ALREADY EXECUTED THIS PLAN THEN DO NOT CONSIDER THIS."
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
return {"coder_messages": [sync_msg]}
|
|
35
|
+
|
|
36
|
+
# normal -> plan : summarise coder activity for the planner
|
|
37
|
+
if prev_mode == "normal" and new_mode == "plan":
|
|
38
|
+
coder_msgs: list[BaseMessage] = last_state.get("coder_messages", [])
|
|
39
|
+
summary = _summarise_coder_activity(coder_msgs)
|
|
40
|
+
|
|
41
|
+
sync_msg = SystemMessage(
|
|
42
|
+
content=(
|
|
43
|
+
"MODE SWITCH: The user has switched you from NORMAL mode to PLAN mode.\n"
|
|
44
|
+
"Below is a compact summary of what the coding agent already did in "
|
|
45
|
+
"the previous (normal) mode. Build your plan on top of this — do not "
|
|
46
|
+
"repeat finished work.\n\n"
|
|
47
|
+
f"{summary}"
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return {"planner_messages": [sync_msg]}
|
|
51
|
+
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _summarise_coder_activity(msgs: list[BaseMessage]) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Cheap deterministic summary: last N AI text contents + the names of any
|
|
58
|
+
tools called.
|
|
59
|
+
"""
|
|
60
|
+
lines = []
|
|
61
|
+
for m in msgs[-10:]: # last N turns only
|
|
62
|
+
# AI text
|
|
63
|
+
content = getattr(m, "content", "")
|
|
64
|
+
if content:
|
|
65
|
+
text = content if isinstance(content, str) else str(content)
|
|
66
|
+
lines.append(f"AI: {text[:400]}")
|
|
67
|
+
|
|
68
|
+
# tool calls
|
|
69
|
+
for tc in (getattr(m, "tool_calls", None) or []):
|
|
70
|
+
lines.append(f" -> tool: {tc['name']}")
|
|
71
|
+
|
|
72
|
+
return "\n".join(lines) if lines else "(no prior activity recorded)"
|
vamp_cli/agent/graph.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from langgraph.graph import StateGraph, START, END
|
|
4
|
+
from langchain_core.messages import AIMessage
|
|
5
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
6
|
+
|
|
7
|
+
from vamp_cli.tools.registry import ToolRegistry
|
|
8
|
+
from vamp_cli.agent.state import AgentState
|
|
9
|
+
from vamp_cli.agent.nodes import (
|
|
10
|
+
create_agent_node,
|
|
11
|
+
create_planner_node,
|
|
12
|
+
create_plan_approval_node,
|
|
13
|
+
create_tool_classify_node,
|
|
14
|
+
create_tool_approve_node,
|
|
15
|
+
create_tool_execute_node,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
MAX_ITERATIONS = 15
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def route_start(state) -> Literal["agent", "planner"]:
|
|
22
|
+
return "planner" if state["mode"] == "plan" else "agent"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def should_continue(state) -> Literal["classify", "__end__"]:
|
|
26
|
+
if state["iteration"] >= MAX_ITERATIONS:
|
|
27
|
+
return "__end__"
|
|
28
|
+
last = state["coder_messages"][-1]
|
|
29
|
+
if not isinstance(last, AIMessage) or not last.tool_calls:
|
|
30
|
+
return "__end__"
|
|
31
|
+
return "classify"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def route_after_planner(state) -> Literal["classify", "__end__"]:
|
|
35
|
+
if state["iteration"] >= MAX_ITERATIONS:
|
|
36
|
+
return "__end__"
|
|
37
|
+
msgs = state["planner_messages"]
|
|
38
|
+
last = next((m for m in reversed(msgs) if isinstance(m, AIMessage)), None)
|
|
39
|
+
if last is None or not last.tool_calls:
|
|
40
|
+
return "__end__"
|
|
41
|
+
return "classify"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def route_after_execute(state) -> Literal["coder", "planner", "plan_approver"]:
|
|
45
|
+
"""Reads the (still-populated) pipeline channel + last planner AIMessage."""
|
|
46
|
+
channel = state.get("_pending_tool_channel")
|
|
47
|
+
|
|
48
|
+
if channel == "planner_messages":
|
|
49
|
+
msgs = state["planner_messages"]
|
|
50
|
+
last_ai = next((m for m in reversed(msgs) if isinstance(m, AIMessage)), None)
|
|
51
|
+
if last_ai and last_ai.tool_calls:
|
|
52
|
+
if any(tc["name"] == "submit_plan" for tc in last_ai.tool_calls):
|
|
53
|
+
return "plan_approver"
|
|
54
|
+
return "planner"
|
|
55
|
+
|
|
56
|
+
return "coder"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def route_after_plan_approver(state) -> Literal["coder", "__end__"]:
|
|
60
|
+
return "coder" if state.get("requires_execution") else "__end__"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_graph(
|
|
64
|
+
coder_model,
|
|
65
|
+
planner_model,
|
|
66
|
+
tool_registry: ToolRegistry,
|
|
67
|
+
checkpointer=None,
|
|
68
|
+
):
|
|
69
|
+
|
|
70
|
+
coder_node = create_agent_node(coder_model)
|
|
71
|
+
planner_node = create_planner_node(planner_model)
|
|
72
|
+
approver_node = create_plan_approval_node(planner_model)
|
|
73
|
+
|
|
74
|
+
coder_classify = create_tool_classify_node("coder_messages")
|
|
75
|
+
planner_classify = create_tool_classify_node("planner_messages")
|
|
76
|
+
|
|
77
|
+
approve_tools = create_tool_approve_node()
|
|
78
|
+
execute_tools = create_tool_execute_node(tool_registry)
|
|
79
|
+
|
|
80
|
+
g = StateGraph(AgentState)
|
|
81
|
+
|
|
82
|
+
g.add_node("coder", coder_node)
|
|
83
|
+
g.add_node("planner", planner_node)
|
|
84
|
+
|
|
85
|
+
g.add_node("plan_approver", approver_node)
|
|
86
|
+
|
|
87
|
+
g.add_node("coder_classify", coder_classify)
|
|
88
|
+
g.add_node("planner_classify", planner_classify)
|
|
89
|
+
|
|
90
|
+
g.add_node("approve_tools", approve_tools)
|
|
91
|
+
g.add_node("execute_tools", execute_tools)
|
|
92
|
+
|
|
93
|
+
# START
|
|
94
|
+
g.add_conditional_edges(
|
|
95
|
+
START,
|
|
96
|
+
route_start,
|
|
97
|
+
{
|
|
98
|
+
"agent": "coder",
|
|
99
|
+
"planner": "planner"
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# PLANNER -> classify -> approve -> execute -> route
|
|
104
|
+
g.add_conditional_edges(
|
|
105
|
+
"planner",
|
|
106
|
+
route_after_planner,
|
|
107
|
+
{
|
|
108
|
+
"classify": "planner_classify",
|
|
109
|
+
"__end__": END
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
g.add_edge(
|
|
114
|
+
"planner_classify",
|
|
115
|
+
"approve_tools"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# CODER -> classify -> approve -> execute -> route
|
|
119
|
+
g.add_conditional_edges(
|
|
120
|
+
"coder",
|
|
121
|
+
should_continue,
|
|
122
|
+
{
|
|
123
|
+
"classify": "coder_classify",
|
|
124
|
+
"__end__": END
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
g.add_edge(
|
|
129
|
+
"coder_classify",
|
|
130
|
+
"approve_tools"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# shared tail
|
|
134
|
+
g.add_edge(
|
|
135
|
+
"approve_tools",
|
|
136
|
+
"execute_tools"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
g.add_conditional_edges(
|
|
140
|
+
"execute_tools",
|
|
141
|
+
route_after_execute,
|
|
142
|
+
{
|
|
143
|
+
"coder": "coder",
|
|
144
|
+
"planner": "planner",
|
|
145
|
+
"plan_approver": "plan_approver",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# plan approval gate
|
|
150
|
+
g.add_conditional_edges(
|
|
151
|
+
"plan_approver",
|
|
152
|
+
route_after_plan_approver,
|
|
153
|
+
{
|
|
154
|
+
"coder": "coder",
|
|
155
|
+
"__end__": END
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return g.compile(checkpointer=checkpointer or MemorySaver())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
TODO_INSTRUCTIONS = """\
|
|
2
|
+
You maintain a todo list for every non-trivial task, using the `write_update_todos` tool.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Call `write_update_todos` BEFORE you start making changes, and again whenever the
|
|
6
|
+
status of any item changes.
|
|
7
|
+
- Always send the COMPLETE list — it replaces the previous one.
|
|
8
|
+
- Exactly one item may be `in_progress` at a time.
|
|
9
|
+
- Mark an item `completed` as soon as it is verified done, then set the next
|
|
10
|
+
item to `in_progress` BEFORE starting it.
|
|
11
|
+
- Never mark something `completed` if verification failed or it is only
|
|
12
|
+
partially done — use `blocked` instead.
|
|
13
|
+
- Keep item ids stable across updates.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
PLAN_INSTRUCTIONS = """\
|
|
17
|
+
The plan above was produced by a separate planning agent after repository exploration. Treat it as the intended implementation plan, not as the user's original request. So execute the plan
|
|
18
|
+
"""
|
vamp_cli/agent/nodes.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
from prompt_toolkit import print_formatted_text
|
|
6
|
+
from prompt_toolkit.formatted_text import FormattedText, HTML
|
|
7
|
+
|
|
8
|
+
from langgraph.graph import END
|
|
9
|
+
from langgraph.types import interrupt, Command
|
|
10
|
+
from langchain_core.runnables import Runnable, RunnableConfig
|
|
11
|
+
from langchain_core.messages import ToolMessage, SystemMessage, AIMessage, BaseMessage
|
|
12
|
+
|
|
13
|
+
from vamp_cli.tools.registry import ToolRegistry
|
|
14
|
+
from vamp_cli.agent.state import AgentState, TodoItem
|
|
15
|
+
from vamp_cli.agent.state import AgentState
|
|
16
|
+
from vamp_cli.agent.status import FeedbackResponse
|
|
17
|
+
from vamp_cli.agent.instructions import TODO_INSTRUCTIONS, PLAN_INSTRUCTIONS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_planner_node(model: Runnable):
|
|
22
|
+
|
|
23
|
+
async def planner_node(state: AgentState, config: RunnableConfig):
|
|
24
|
+
# The planner gets the same model or different model (if user as configured for planner explicitly) interface,
|
|
25
|
+
# with a restricted read-only tool registry.
|
|
26
|
+
|
|
27
|
+
messages = state["planner_messages"]
|
|
28
|
+
|
|
29
|
+
# response = await model.ainvoke(messages)
|
|
30
|
+
response = await model.with_config(config).ainvoke(messages)
|
|
31
|
+
pending = next(
|
|
32
|
+
(tc for tc in (response.tool_calls or []) if tc["name"] == "submit_plan"),
|
|
33
|
+
None
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if response.tool_calls:
|
|
37
|
+
print_formatted_text(
|
|
38
|
+
FormattedText([(
|
|
39
|
+
"dim",
|
|
40
|
+
"\n\n[planner] calling:"
|
|
41
|
+
)]),
|
|
42
|
+
end="\n"
|
|
43
|
+
)
|
|
44
|
+
for tc in response.tool_calls:
|
|
45
|
+
print_formatted_text(
|
|
46
|
+
FormattedText([(
|
|
47
|
+
"dim",
|
|
48
|
+
f">> {tc['name']}({tc['args']})"
|
|
49
|
+
)]),
|
|
50
|
+
end="\n"
|
|
51
|
+
)
|
|
52
|
+
# print("Tool: ", tc['name'])
|
|
53
|
+
# print("Arguments: ", tc['args'])
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
"planner_messages": [response],
|
|
57
|
+
"plan": pending["args"].get("plan") if pending else None,
|
|
58
|
+
"iteration": state["iteration"] + 1,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return planner_node
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_plan_approval_node(model: Runnable):
|
|
65
|
+
|
|
66
|
+
async def plan_approval_node(state: AgentState, config: RunnableConfig):
|
|
67
|
+
|
|
68
|
+
if not state.get("plan"):
|
|
69
|
+
return {"requires_execution": False}
|
|
70
|
+
|
|
71
|
+
checkpoint_payload = {
|
|
72
|
+
"implementation_permission": True,
|
|
73
|
+
"plan": state["plan"],
|
|
74
|
+
"warning": "Asking before implementing this plan"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# execution halts here
|
|
78
|
+
fb: FeedbackResponse = interrupt(value=checkpoint_payload, response_schema=FeedbackResponse)
|
|
79
|
+
return {"requires_execution": fb.approved}
|
|
80
|
+
|
|
81
|
+
return plan_approval_node
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def create_agent_node(model: Runnable):
|
|
86
|
+
|
|
87
|
+
async def agent_node(state: AgentState, config: RunnableConfig):
|
|
88
|
+
|
|
89
|
+
message_to_send = list(state["coder_messages"])
|
|
90
|
+
|
|
91
|
+
if state["mode"] == "plan":
|
|
92
|
+
plan = state["plan"]
|
|
93
|
+
|
|
94
|
+
if plan:
|
|
95
|
+
plan_message = SystemMessage(content=f"Here is the project plan:\n{state['plan']}")
|
|
96
|
+
plan_instruction = SystemMessage(content=PLAN_INSTRUCTIONS)
|
|
97
|
+
|
|
98
|
+
message_to_send += [plan_message] + [plan_instruction]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if not state.get("todo"):
|
|
103
|
+
message_to_send.append(SystemMessage(content=TODO_INSTRUCTIONS))
|
|
104
|
+
|
|
105
|
+
if state.get("todo"):
|
|
106
|
+
turns_since_update = _turns_since_todo_update(state["coder_messages"])
|
|
107
|
+
if turns_since_update >= 10:
|
|
108
|
+
message_to_send.append(
|
|
109
|
+
SystemMessage(content=_format_todo_reminder(state["todo"]))
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
response: AIMessage = await model.with_config(config).ainvoke(message_to_send)
|
|
113
|
+
|
|
114
|
+
# print("\n\nCODER------AI-RESPONSE:\n.\n.\n", response, "\n\n")
|
|
115
|
+
|
|
116
|
+
if response.tool_calls:
|
|
117
|
+
print_formatted_text(
|
|
118
|
+
FormattedText([(
|
|
119
|
+
"dim",
|
|
120
|
+
"\n\n[coder] calling:"
|
|
121
|
+
)]),
|
|
122
|
+
end="\n"
|
|
123
|
+
)
|
|
124
|
+
for tc in response.tool_calls:
|
|
125
|
+
print_formatted_text(
|
|
126
|
+
FormattedText([(
|
|
127
|
+
"dim",
|
|
128
|
+
f">> {tc['name']}({tc['args']})"
|
|
129
|
+
)]),
|
|
130
|
+
end="\n"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"coder_messages": [response],
|
|
135
|
+
# "is_plan_injected": plan_injected,
|
|
136
|
+
"iteration": state["iteration"] + 1,
|
|
137
|
+
}
|
|
138
|
+
return agent_node
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
DANGEROUS_PATTERNS = ("rm ", "rmdir ", "curl ", "wget ", "pip install ", "shutdown", "dd ")
|
|
142
|
+
|
|
143
|
+
def _is_dangerous(tool_name: str, tool_args: dict) -> bool:
|
|
144
|
+
is_bash = tool_name == "execute_command" or "execute" in tool_name.lower()
|
|
145
|
+
if not is_bash:
|
|
146
|
+
return False
|
|
147
|
+
cmd = str(tool_args.get("command", "")).lower()
|
|
148
|
+
return any(p in cmd for p in DANGEROUS_PATTERNS)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_tool_classify_node(channel: str):
|
|
152
|
+
async def tool_classify_node(state: AgentState, config: RunnableConfig):
|
|
153
|
+
last = state[channel][-1]
|
|
154
|
+
|
|
155
|
+
if not isinstance(last, AIMessage) or not last.tool_calls:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
f"classify_node invoked but last {channel} entry "
|
|
158
|
+
f"is {type(last).__name__} with no tool calls"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
dangerous_ids = [
|
|
162
|
+
tc["id"]
|
|
163
|
+
for tc in last.tool_calls
|
|
164
|
+
if _is_dangerous(tc["name"], tc.get("args") or {})
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"_pending_tool_channel": channel,
|
|
169
|
+
"_pending_tool_calls": [dict(tc) for tc in last.tool_calls],
|
|
170
|
+
"_dangerous_tool_call_ids": dangerous_ids,
|
|
171
|
+
"_approved_tool_call_ids": [],
|
|
172
|
+
"_denied_tool_call_ids": [],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return tool_classify_node
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def create_tool_approve_node():
|
|
179
|
+
async def tool_approve_node(state: AgentState, config: RunnableConfig):
|
|
180
|
+
pending = state.get("_pending_tool_calls", [])
|
|
181
|
+
dangerous_ids = state.get("_dangerous_tool_call_ids", [])
|
|
182
|
+
all_ids = [tc["id"] for tc in pending]
|
|
183
|
+
|
|
184
|
+
# No dangerous calls: auto-approve everything, no interrupt.
|
|
185
|
+
if not dangerous_ids:
|
|
186
|
+
return {
|
|
187
|
+
"_approved_tool_call_ids": all_ids,
|
|
188
|
+
"_denied_tool_call_ids": [],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
dangerous_calls = [tc for tc in pending if tc["id"] in dangerous_ids]
|
|
192
|
+
|
|
193
|
+
payload = {
|
|
194
|
+
"requires_tool_approval": True,
|
|
195
|
+
"dangerous_calls": [
|
|
196
|
+
{
|
|
197
|
+
"tool_call_id": tc["id"],
|
|
198
|
+
"tool_name": tc["name"],
|
|
199
|
+
"tool_args": tc.get("args") or {},
|
|
200
|
+
}
|
|
201
|
+
for tc in dangerous_calls
|
|
202
|
+
],
|
|
203
|
+
"safe_call_count": len(pending) - len(dangerous_calls),
|
|
204
|
+
"warning": (
|
|
205
|
+
"The following tool calls were flagged as dangerous. "
|
|
206
|
+
"Approving will execute them (plus all safe calls) exactly once."
|
|
207
|
+
),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Halts here. On resume, returns the FeedbackResponse instead of raising.
|
|
211
|
+
fb: FeedbackResponse = interrupt(value=payload, response_schema=FeedbackResponse)
|
|
212
|
+
|
|
213
|
+
safe_ids = [i for i in all_ids if i not in dangerous_ids]
|
|
214
|
+
|
|
215
|
+
if fb.approved:
|
|
216
|
+
return {
|
|
217
|
+
"_approved_tool_call_ids": safe_ids + dangerous_ids,
|
|
218
|
+
"_denied_tool_call_ids": [],
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
"_approved_tool_call_ids": safe_ids,
|
|
222
|
+
"_denied_tool_call_ids": dangerous_ids,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return tool_approve_node
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def create_tool_execute_node(tool_registry: ToolRegistry):
|
|
229
|
+
async def tool_execute_node(state: AgentState, config: RunnableConfig):
|
|
230
|
+
channel = state["_pending_tool_channel"]
|
|
231
|
+
pending = state.get("_pending_tool_calls", [])
|
|
232
|
+
approved = set(state.get("_approved_tool_call_ids", []))
|
|
233
|
+
denied = set(state.get("_denied_tool_call_ids", []))
|
|
234
|
+
|
|
235
|
+
tool_messages: list[ToolMessage] = []
|
|
236
|
+
todo_update = None
|
|
237
|
+
|
|
238
|
+
interrupted = False
|
|
239
|
+
|
|
240
|
+
emitter = (config.get("configurable") or {}).get("emitter")
|
|
241
|
+
node_name = channel.replace("_messages", "") if channel else None
|
|
242
|
+
|
|
243
|
+
for tc in pending:
|
|
244
|
+
tc_id, tc_name, tc_args = tc["id"], tc["name"], tc.get("args") or {}
|
|
245
|
+
|
|
246
|
+
if tc_id in denied:
|
|
247
|
+
content=(
|
|
248
|
+
"CRITICAL ACCESS DENIED: The security administrator has "
|
|
249
|
+
"EXPLICITLY FORBIDDEN executing the denied task in any way. "
|
|
250
|
+
"MANDATE: cease all attempts to execute the denied task. "
|
|
251
|
+
"Abort this task immediately and explain the restriction "
|
|
252
|
+
"to the user. Do not retry. Move on to another task."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
tool_messages.append(ToolMessage(
|
|
256
|
+
content=content,
|
|
257
|
+
tool_call_id=tc_id,
|
|
258
|
+
name=tc_name,
|
|
259
|
+
status="error",
|
|
260
|
+
))
|
|
261
|
+
|
|
262
|
+
if emitter is not None:
|
|
263
|
+
emitter.emit_tool_result(
|
|
264
|
+
tool_call_id=tc_id, tool_name=tc_name, node=node_name,
|
|
265
|
+
status="denied", content=content, duration_ms=0,
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
if tc_id not in approved:
|
|
270
|
+
content = f"Tool '{tc_name}' was not approved for execution."
|
|
271
|
+
tool_messages.append(ToolMessage(
|
|
272
|
+
content=content,
|
|
273
|
+
tool_call_id=tc_id, name=tc_name, status="error",
|
|
274
|
+
))
|
|
275
|
+
if emitter is not None:
|
|
276
|
+
emitter.emit_tool_result(
|
|
277
|
+
tool_call_id=tc_id, tool_name=tc_name, node=node_name,
|
|
278
|
+
status="error", content=content, duration_ms=0,
|
|
279
|
+
)
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
if interrupted:
|
|
283
|
+
content="[Skipped — session was interrupted by user before this tool ran]"
|
|
284
|
+
tool_messages.append(ToolMessage(
|
|
285
|
+
content=content,
|
|
286
|
+
tool_call_id=tc_id, name=tc_name, status="error",
|
|
287
|
+
))
|
|
288
|
+
|
|
289
|
+
if emitter is not None:
|
|
290
|
+
emitter.emit_tool_result(
|
|
291
|
+
tool_call_id=tc_id, tool_name=tc_name, node=node_name,
|
|
292
|
+
status="error", content=content, duration_ms=0,
|
|
293
|
+
)
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
start = time.time()
|
|
297
|
+
try:
|
|
298
|
+
result = await tool_registry.call(tc_name, tc_args)
|
|
299
|
+
except (asyncio.CancelledError, KeyboardInterrupt):
|
|
300
|
+
interrupted = True
|
|
301
|
+
result = "[Tool execution was interrupted by user]"
|
|
302
|
+
status = "error"
|
|
303
|
+
except Exception as e:
|
|
304
|
+
result = {"error": f"Tool '{tc_name}' failed: {e}"}
|
|
305
|
+
status = "error"
|
|
306
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
307
|
+
|
|
308
|
+
if tc_name == "write_update_todos" and isinstance(result, str) and not interrupted:
|
|
309
|
+
try:
|
|
310
|
+
todo_update = json.loads(result)
|
|
311
|
+
except json.JSONDecodeError:
|
|
312
|
+
todo_update = None
|
|
313
|
+
|
|
314
|
+
string_result = result if isinstance(result, str) else json.dumps(result)
|
|
315
|
+
tool_messages.append(ToolMessage(
|
|
316
|
+
content=string_result,
|
|
317
|
+
tool_call_id=tc_id,
|
|
318
|
+
name=tc_name,
|
|
319
|
+
status="success" if isinstance(result, str) else "error",
|
|
320
|
+
))
|
|
321
|
+
if emitter is not None:
|
|
322
|
+
emitter.emit_tool_result(
|
|
323
|
+
tool_call_id=tc_id, tool_name=tc_name, node=node_name,
|
|
324
|
+
status="success", content=string_result,
|
|
325
|
+
duration_ms=duration_ms,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
update = {
|
|
329
|
+
channel: tool_messages,
|
|
330
|
+
"_pending_tool_calls": [],
|
|
331
|
+
"_dangerous_tool_call_ids": [],
|
|
332
|
+
"_approved_tool_call_ids": [],
|
|
333
|
+
"_denied_tool_call_ids": [],
|
|
334
|
+
}
|
|
335
|
+
if todo_update is not None:
|
|
336
|
+
update["todo"] = todo_update
|
|
337
|
+
|
|
338
|
+
# if interrupted:
|
|
339
|
+
# # Save the state update, then END the graph invocation.
|
|
340
|
+
# # Control returns to main(); the prompt shows again.
|
|
341
|
+
# return Command(update=update, goto=END)
|
|
342
|
+
|
|
343
|
+
return update
|
|
344
|
+
|
|
345
|
+
return tool_execute_node
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _turns_since_todo_update(messages: list[BaseMessage]) -> int:
|
|
349
|
+
"""How many AIMessages have passed since the last write_update_todos call."""
|
|
350
|
+
count = 0
|
|
351
|
+
for msg in reversed(messages):
|
|
352
|
+
if isinstance(msg, AIMessage) and msg.tool_calls:
|
|
353
|
+
if any(tc["name"] == "write_update_todos" for tc in msg.tool_calls):
|
|
354
|
+
return count
|
|
355
|
+
count += 1
|
|
356
|
+
elif isinstance(msg, AIMessage):
|
|
357
|
+
count += 1
|
|
358
|
+
return count
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _format_todo_reminder(todos: list[TodoItem]) -> str:
|
|
362
|
+
marks = {
|
|
363
|
+
"pending": "[ ]",
|
|
364
|
+
"in_progress": "[~]",
|
|
365
|
+
"completed": "[x]",
|
|
366
|
+
"blocked": "[!]",
|
|
367
|
+
}
|
|
368
|
+
lines = ["Your current todo list:"]
|
|
369
|
+
for t in todos:
|
|
370
|
+
lines.append(f" {marks[t['status']]} {t['id']}. {t['description']}")
|
|
371
|
+
lines.append(
|
|
372
|
+
"Continue with the next in_progress or pending item. "
|
|
373
|
+
"Call write_update_todos when the status of any item changes."
|
|
374
|
+
)
|
|
375
|
+
return "\n".join(lines)
|
vamp_cli/agent/roles.py
ADDED
|
File without changes
|