Skip to content

Commit 7886cda

Browse files
authored
Merge pull request #94 from VectorlyApp/agent-edit-routine-tool
agent can edit routine
2 parents 90667f2 + 950ceea commit 7886cda

4 files changed

Lines changed: 457 additions & 74 deletions

File tree

scripts/run_guide_agent.py

Lines changed: 237 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,19 @@
1010
/unload Unload the current routine
1111
/show Show current routine details
1212
/validate Validate the current routine
13-
/execute [params.json] Execute the loaded routine
13+
/execute [params.json] Execute the loaded routine
14+
/diff Show pending suggested edit diff
15+
/accept Accept pending suggested edit
16+
/reject Reject pending suggested edit
1417
/status Show current state
18+
/chats Show all messages in the thread
1519
/reset Start a new conversation
1620
/help Show help
1721
/quit Exit
1822
"""
1923

2024
import argparse
25+
import difflib
2126
import json
2227
import logging
2328
import sys
@@ -41,8 +46,9 @@
4146
from web_hacker.data_models.llms.vendors import OpenAIModel
4247
from web_hacker.data_models.llms.interaction import (
4348
ChatMessageType,
44-
EmittedChatMessage,
49+
EmittedMessage,
4550
PendingToolInvocation,
51+
SuggestedEditRoutine,
4652
ToolInvocationStatus,
4753
)
4854
from web_hacker.data_models.routine.routine import Routine
@@ -109,11 +115,18 @@ def print_welcome(model: str) -> None:
109115
110116
[bold]Commands:[/bold]
111117
[cyan]/load <routine.json>[/cyan] Load a routine file (auto-reloads on edits)
118+
[cyan]/unload[/cyan] Unload the current routine
112119
[cyan]/show[/cyan] Show current routine details
113120
[cyan]/validate[/cyan] Validate the current routine
114121
[cyan]/execute \[params.json][/cyan] Execute the loaded routine
122+
[cyan]/diff[/cyan] Show pending suggested edit diff
123+
[cyan]/accept[/cyan] Accept pending suggested edit
124+
[cyan]/reject[/cyan] Reject pending suggested edit
115125
[cyan]/status[/cyan] Show current state
126+
[cyan]/chats[/cyan] Show all messages in the thread
127+
[cyan]/reset[/cyan] Start a new conversation
116128
[cyan]/help[/cyan] Show all commands
129+
[cyan]/quit[/cyan] Exit
117130
118131
[bold]Links:[/bold]
119132
[link=https://vectorly.app/docs]https://vectorly.app/docs[/link]
@@ -175,7 +188,8 @@ def print_tool_result(
175188
elif invocation.status == ToolInvocationStatus.FAILED:
176189
console.print("[bold red]✗ Tool execution failed[/bold red]")
177190
if error:
178-
console.print(f"[red] Error: {error}[/red]")
191+
console.print()
192+
console.print(Panel(error, title="Error", style="red", box=box.ROUNDED))
179193

180194
console.print()
181195

@@ -286,6 +300,7 @@ def __init__(
286300
) -> None:
287301
"""Initialize the terminal chat interface."""
288302
self._pending_invocation: PendingToolInvocation | None = None
303+
self._pending_suggested_edit: SuggestedEditRoutine | None = None
289304
self._streaming_started: bool = False
290305
self._data_store = data_store
291306
self._loaded_routine_path: Path | None = None
@@ -297,6 +312,70 @@ def __init__(
297312
data_store=data_store,
298313
)
299314

315+
def _persist_routine(self, routine_dict: dict[str, Any]) -> None:
316+
"""
317+
Persist the routine to the loaded file path.
318+
319+
Called when user approves the update_routine tool call.
320+
Shows a diff and overwrites the file with the new routine JSON.
321+
"""
322+
if self._loaded_routine_path is None:
323+
console.print()
324+
console.print("[yellow]⚠ No file loaded - routine updated in memory only[/yellow]")
325+
console.print()
326+
return
327+
328+
try:
329+
# Read old content for diff
330+
old_content = ""
331+
if self._loaded_routine_path.exists():
332+
with open(self._loaded_routine_path, encoding="utf-8") as f:
333+
old_content = f.read()
334+
335+
# Format new content
336+
new_content = json.dumps(routine_dict, indent=2)
337+
338+
# Generate and display diff
339+
old_lines = old_content.splitlines(keepends=True)
340+
new_lines = new_content.splitlines(keepends=True)
341+
diff = difflib.unified_diff(
342+
old_lines,
343+
new_lines,
344+
fromfile=str(self._loaded_routine_path),
345+
tofile=str(self._loaded_routine_path),
346+
lineterm="",
347+
)
348+
349+
diff_lines = list(diff)
350+
if diff_lines:
351+
console.print()
352+
console.print("[bold cyan]Diff:[/bold cyan]")
353+
for line in diff_lines:
354+
line = line.rstrip("\n")
355+
if line.startswith("+++") or line.startswith("---"):
356+
console.print(f"[bold]{line}[/bold]")
357+
elif line.startswith("@@"):
358+
console.print(f"[cyan]{line}[/cyan]")
359+
elif line.startswith("+"):
360+
console.print(f"[green]{line}[/green]")
361+
elif line.startswith("-"):
362+
console.print(f"[red]{line}[/red]")
363+
else:
364+
console.print(line)
365+
console.print()
366+
367+
# Write new content
368+
with open(self._loaded_routine_path, "w", encoding="utf-8") as f:
369+
f.write(new_content)
370+
371+
console.print(f"[green]✓ Routine saved to {self._loaded_routine_path}[/green]")
372+
console.print()
373+
374+
except Exception as e:
375+
console.print()
376+
console.print(f"[red]✗ Failed to save routine: {e}[/red]")
377+
console.print()
378+
300379
def _get_prompt(self) -> str:
301380
"""Get the input prompt with routine name if loaded."""
302381
routine_str = self._agent.routine_state.current_routine_str
@@ -323,7 +402,7 @@ def _handle_stream_chunk(self, chunk: str) -> None:
323402
# Use plain print for streaming - Rich console.print breaks on char-by-char output
324403
print(chunk, end="", flush=True)
325404

326-
def _handle_message(self, message: EmittedChatMessage) -> None:
405+
def _handle_message(self, message: EmittedMessage) -> None:
327406
"""Handle messages emitted by the Guide Agent."""
328407
if message.type == ChatMessageType.CHAT_RESPONSE:
329408
if self._streaming_started:
@@ -346,6 +425,14 @@ def _handle_message(self, message: EmittedChatMessage) -> None:
346425
message.error,
347426
)
348427

428+
elif message.type == ChatMessageType.SUGGESTED_EDIT:
429+
if message.suggested_edit:
430+
self._pending_suggested_edit = message.suggested_edit
431+
console.print()
432+
console.print("[bold yellow]📝 Agent suggested a routine edit[/bold yellow]")
433+
console.print("[dim]Use /diff to see changes, /accept to apply, /reject to discard[/dim]")
434+
console.print()
435+
349436
elif message.type == ChatMessageType.ERROR:
350437
print_error(message.error or "Unknown error")
351438

@@ -504,6 +591,46 @@ def _handle_validate_command(self) -> None:
504591
console.print(Panel(error, title="Validation Error", style="red", box=box.ROUNDED))
505592
console.print()
506593

594+
def _handle_chats_command(self) -> None:
595+
"""Handle /chats command to show all messages in the thread."""
596+
chats = self._agent.get_chats()
597+
if not chats:
598+
console.print()
599+
console.print("[yellow]No messages in thread yet.[/yellow]")
600+
console.print()
601+
return
602+
603+
console.print()
604+
console.print(f"[bold cyan]Chat History ({len(chats)} messages)[/bold cyan]")
605+
console.print()
606+
607+
role_styles = {"user": "green", "assistant": "cyan", "system": "yellow", "tool": "magenta"}
608+
609+
for i, chat in enumerate(chats, 1):
610+
role_style = role_styles.get(chat.role.value, "white")
611+
612+
# TOOL role = tool result
613+
if chat.role.value == "tool":
614+
content = chat.content.replace("\n", " ")[:50]
615+
tool_id = chat.tool_call_id[:8] + "..." if chat.tool_call_id else "?"
616+
console.print(f"[dim]{i}.[/dim] [{role_style}]TOOL_RESULT[/{role_style}] [dim]({tool_id})[/dim] {escape(content)}...")
617+
continue
618+
619+
# ASSISTANT with tool_calls = tool request
620+
if chat.role.value == "assistant" and chat.tool_calls:
621+
tool_names = ", ".join(tc.tool_name for tc in chat.tool_calls)
622+
content = chat.content.replace("\n", " ")[:30] if chat.content else ""
623+
prefix = f"{escape(content)}... " if content else ""
624+
console.print(f"[dim]{i}.[/dim] [{role_style}]ASSISTANT[/{role_style}] {prefix}[yellow]→ {tool_names}[/yellow]")
625+
continue
626+
627+
# Regular message - single line, ~50 chars
628+
content = chat.content.replace("\n", " ")[:50]
629+
suffix = "..." if len(chat.content) > 50 else ""
630+
console.print(f"[dim]{i}.[/dim] [{role_style}]{chat.role.value.upper()}[/{role_style}] {escape(content)}{suffix}")
631+
632+
console.print()
633+
507634
def _handle_status_command(self) -> None:
508635
"""Handle /status command to show current state."""
509636
table = Table(box=box.ROUNDED, show_header=False)
@@ -681,6 +808,89 @@ def _handle_execute_command(self, params_path: str | None) -> None:
681808
console.print(f"[bold red]✗ Execution error: {e}[/bold red]")
682809
console.print()
683810

811+
def _handle_diff_command(self) -> None:
812+
"""Handle /diff command to show pending suggested edit."""
813+
if not self._pending_suggested_edit:
814+
console.print()
815+
console.print("[yellow]No pending suggested edit.[/yellow]")
816+
console.print()
817+
return
818+
819+
# Get current routine
820+
current_str = self._agent.routine_state.current_routine_str or "{}"
821+
# Serialize Routine object to JSON for diff
822+
new_str = json.dumps(self._pending_suggested_edit.routine.model_dump(), indent=2)
823+
824+
# Generate unified diff
825+
current_lines = current_str.splitlines(keepends=True)
826+
new_lines = new_str.splitlines(keepends=True)
827+
diff = difflib.unified_diff(
828+
current_lines,
829+
new_lines,
830+
fromfile="current",
831+
tofile="suggested",
832+
lineterm="",
833+
)
834+
835+
diff_lines = list(diff)
836+
if diff_lines:
837+
console.print()
838+
console.print("[bold cyan]Suggested Edit Diff:[/bold cyan]")
839+
for line in diff_lines:
840+
line = line.rstrip("\n")
841+
if line.startswith("+++") or line.startswith("---"):
842+
console.print(f"[bold]{line}[/bold]")
843+
elif line.startswith("@@"):
844+
console.print(f"[cyan]{line}[/cyan]")
845+
elif line.startswith("+"):
846+
console.print(f"[green]{line}[/green]")
847+
elif line.startswith("-"):
848+
console.print(f"[red]{line}[/red]")
849+
else:
850+
console.print(line)
851+
console.print()
852+
else:
853+
console.print()
854+
console.print("[yellow]No differences found.[/yellow]")
855+
console.print()
856+
857+
def _handle_accept_command(self) -> None:
858+
"""Handle /accept command to approve pending edit."""
859+
if not self._pending_suggested_edit:
860+
console.print()
861+
console.print("[yellow]No pending suggested edit to accept.[/yellow]")
862+
console.print()
863+
return
864+
865+
# Get routine from pending edit
866+
routine = self._pending_suggested_edit.routine
867+
routine_dict = routine.model_dump()
868+
routine_str = json.dumps(routine_dict)
869+
870+
# Update agent's routine state
871+
self._agent.routine_state.update_current_routine(routine_str)
872+
873+
# Persist to file (reuses existing _persist_routine method)
874+
self._persist_routine(routine_dict)
875+
876+
console.print()
877+
console.print("[bold green]✓ Edit accepted and applied[/bold green]")
878+
console.print()
879+
self._pending_suggested_edit = None
880+
881+
def _handle_reject_command(self) -> None:
882+
"""Handle /reject command to reject pending edit."""
883+
if not self._pending_suggested_edit:
884+
console.print()
885+
console.print("[yellow]No pending suggested edit to reject.[/yellow]")
886+
console.print()
887+
return
888+
889+
console.print()
890+
console.print("[yellow]✗ Edit rejected[/yellow]")
891+
console.print()
892+
self._pending_suggested_edit = None
893+
684894
def run(self) -> None:
685895
"""Run the interactive chat loop."""
686896
print_welcome(str(self._agent.llm_model))
@@ -720,7 +930,11 @@ def run(self) -> None:
720930
[cyan]/show[/cyan] Show current routine details
721931
[cyan]/validate[/cyan] Validate the current routine
722932
[cyan]/execute \[params.json][/cyan] Execute the loaded routine
933+
[cyan]/diff[/cyan] Show pending suggested edit diff
934+
[cyan]/accept[/cyan] Accept pending suggested edit
935+
[cyan]/reject[/cyan] Reject pending suggested edit
723936
[cyan]/status[/cyan] Show current state
937+
[cyan]/chats[/cyan] Show all messages in the thread
724938
[cyan]/reset[/cyan] Start a new conversation
725939
[cyan]/help[/cyan] Show this help message
726940
[cyan]/quit[/cyan] Exit
@@ -729,7 +943,8 @@ def run(self) -> None:
729943
- After /load, edit the file externally and changes are picked up automatically
730944
- The prompt shows the loaded routine name: [dim]You (routine_name)>[/dim]
731945
- Use /execute without params to enter values interactively
732-
- Ask the agent to validate, explain, or debug your routine""",
946+
- Ask the agent to validate, explain, or debug your routine
947+
- When agent suggests edits, use /diff, /accept, or /reject to review them""",
733948
title="[bold magenta]Help[/bold magenta]",
734949
border_style="magenta",
735950
box=box.ROUNDED,
@@ -740,6 +955,7 @@ def run(self) -> None:
740955
if cmd == "/reset":
741956
self._agent.reset()
742957
self._pending_invocation = None
958+
self._pending_suggested_edit = None
743959
self._loaded_routine_path = None
744960
self._last_execution_ok = None
745961
console.print()
@@ -751,6 +967,10 @@ def run(self) -> None:
751967
self._handle_status_command()
752968
continue
753969

970+
if cmd == "/chats":
971+
self._handle_chats_command()
972+
continue
973+
754974
if cmd == "/show":
755975
self._handle_show_command()
756976
continue
@@ -759,6 +979,18 @@ def run(self) -> None:
759979
self._handle_validate_command()
760980
continue
761981

982+
if cmd == "/diff":
983+
self._handle_diff_command()
984+
continue
985+
986+
if cmd == "/accept":
987+
self._handle_accept_command()
988+
continue
989+
990+
if cmd == "/reject":
991+
self._handle_reject_command()
992+
continue
993+
762994
if cmd == "/unload":
763995
self._handle_unload_command()
764996
continue

0 commit comments

Comments
 (0)