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/__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)"
@@ -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
+ """
@@ -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)
@@ -0,0 +1,8 @@
1
+ from enum import Enum
2
+
3
+ class AgentRole(Enum):
4
+ DEFAULT = "default"
5
+ DEFAULT_PLANNER = "default_planner"
6
+ PLANNER = "planner"
7
+ CODER = "coder"
8
+
File without changes