akseljoonas HF Staff Claude Opus 4.6 commited on
Commit
392de34
Β·
1 Parent(s): c68afb6

feat: upgrade CLI with local tools, slash commands, and interrupt support

Browse files

Replace sandbox tools with local bash/read/write/edit implementations that
execute directly on the user's machine. Add slash commands (/help, /undo,
/compact, /model, /yolo, /status), Ctrl+C interrupt support, HF token
loading, and missing event handlers. Remove lmnr dependency. Fix dict vs
ToolCall bug in context manager sanitization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

agent/context_manager/manager.py CHANGED
@@ -141,6 +141,56 @@ class ContextManager:
141
  self._patch_dangling_tool_calls()
142
  return self.items
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  def _patch_dangling_tool_calls(self) -> None:
145
  """Add stub tool results for any tool_calls that lack a matching result.
146
 
@@ -171,13 +221,14 @@ class ContextManager:
171
  if getattr(m, "role", None) == "tool"
172
  }
173
  for tc in assistant_msg.tool_calls:
174
- if tc.id not in answered_ids:
 
175
  self.items.append(
176
  Message(
177
  role="tool",
178
  content="Tool was not executed (interrupted or error).",
179
- tool_call_id=tc.id,
180
- name=tc.function.name,
181
  )
182
  )
183
 
 
141
  self._patch_dangling_tool_calls()
142
  return self.items
143
 
144
+ @staticmethod
145
+ def _tc_id(tc) -> str:
146
+ if isinstance(tc, dict):
147
+ return tc.get("id", "")
148
+ return tc.id
149
+
150
+ @staticmethod
151
+ def _tc_func_name(tc) -> str:
152
+ if isinstance(tc, dict):
153
+ fn = tc.get("function", {})
154
+ return fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
155
+ return tc.function.name
156
+
157
+ @staticmethod
158
+ def _tc_func_args(tc) -> str:
159
+ if isinstance(tc, dict):
160
+ fn = tc.get("function", {})
161
+ return fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
162
+ return tc.function.arguments
163
+
164
+ @staticmethod
165
+ def _tc_set_func_args(tc, value: str) -> None:
166
+ if isinstance(tc, dict):
167
+ fn = tc.get("function")
168
+ if isinstance(fn, dict):
169
+ fn["arguments"] = value
170
+ else:
171
+ fn.arguments = value
172
+ else:
173
+ tc.function.arguments = value
174
+
175
+ def _sanitize_tool_calls(self) -> None:
176
+ """Fix malformed tool_call arguments across all assistant messages."""
177
+ import json
178
+
179
+ for msg in self.items:
180
+ if getattr(msg, "role", None) != "assistant":
181
+ continue
182
+ tool_calls = getattr(msg, "tool_calls", None)
183
+ if not tool_calls:
184
+ continue
185
+ for tc in tool_calls:
186
+ try:
187
+ json.loads(self._tc_func_args(tc))
188
+ except (json.JSONDecodeError, TypeError, ValueError):
189
+ logger.warning(
190
+ "Sanitizing malformed arguments for tool_call %s (%s)",
191
+ self._tc_id(tc), self._tc_func_name(tc),
192
+ )
193
+ self._tc_set_func_args(tc, "{}")
194
  def _patch_dangling_tool_calls(self) -> None:
195
  """Add stub tool results for any tool_calls that lack a matching result.
196
 
 
221
  if getattr(m, "role", None) == "tool"
222
  }
223
  for tc in assistant_msg.tool_calls:
224
+ tc_id = self._tc_id(tc)
225
+ if tc_id not in answered_ids:
226
  self.items.append(
227
  Message(
228
  role="tool",
229
  content="Tool was not executed (interrupted or error).",
230
+ tool_call_id=tc_id,
231
+ name=self._tc_func_name(tc),
232
  )
233
  )
234
 
agent/core/agent_loop.py CHANGED
@@ -795,6 +795,8 @@ async def submission_loop(
795
  event_queue: asyncio.Queue,
796
  config: Config | None = None,
797
  tool_router: ToolRouter | None = None,
 
 
798
  ) -> None:
799
  """
800
  Main agent loop - processes submissions and dispatches to handlers.
@@ -802,7 +804,9 @@ async def submission_loop(
802
  """
803
 
804
  # Create session with tool router
805
- session = Session(event_queue, config=config, tool_router=tool_router)
 
 
806
  logger.info("Agent loop started")
807
 
808
  # Retry any failed uploads from previous sessions (fire-and-forget)
 
795
  event_queue: asyncio.Queue,
796
  config: Config | None = None,
797
  tool_router: ToolRouter | None = None,
798
+ session_holder: list | None = None,
799
+ hf_token: str | None = None,
800
  ) -> None:
801
  """
802
  Main agent loop - processes submissions and dispatches to handlers.
 
804
  """
805
 
806
  # Create session with tool router
807
+ session = Session(event_queue, config=config, tool_router=tool_router, hf_token=hf_token)
808
+ if session_holder is not None:
809
+ session_holder[0] = session
810
  logger.info("Agent loop started")
811
 
812
  # Retry any failed uploads from previous sessions (fire-and-forget)
agent/core/session.py CHANGED
@@ -135,6 +135,11 @@ class Session:
135
  def is_cancelled(self) -> bool:
136
  return self._cancelled.is_set()
137
 
 
 
 
 
 
138
  def increment_turn(self) -> None:
139
  """Increment turn counter (called after each user interaction)"""
140
  self.turn_count += 1
 
135
  def is_cancelled(self) -> bool:
136
  return self._cancelled.is_set()
137
 
138
+ def update_model(self, model_name: str) -> None:
139
+ """Switch the active model and update the context window limit."""
140
+ self.config.model_name = model_name
141
+ self.context_manager.max_context = _get_max_tokens_safe(model_name)
142
+
143
  def increment_turn(self) -> None:
144
  """Increment turn counter (called after each user interaction)"""
145
  self.turn_count += 1
agent/core/tools.py CHANGED
@@ -128,11 +128,11 @@ class ToolRouter:
128
  Based on codex-rs/core/src/tools/router.rs
129
  """
130
 
131
- def __init__(self, mcp_servers: dict[str, MCPServerConfig], hf_token: str | None = None):
132
  self.tools: dict[str, ToolSpec] = {}
133
  self.mcp_servers: dict[str, dict[str, Any]] = {}
134
 
135
- for tool in create_builtin_tools():
136
  self.register_tool(tool)
137
 
138
  self.mcp_client: Client | None = None
@@ -273,7 +273,7 @@ class ToolRouter:
273
  # ============================================================================
274
 
275
 
276
- def create_builtin_tools() -> list[ToolSpec]:
277
  """Create built-in tool specifications"""
278
  # in order of importance
279
  tools = [
@@ -350,8 +350,12 @@ def create_builtin_tools() -> list[ToolSpec]:
350
  ),
351
  ]
352
 
353
- # Sandbox tools (highest priority)
354
- tools = get_sandbox_tools() + tools
 
 
 
 
355
 
356
  tool_names = ", ".join([t.name for t in tools])
357
  logger.info(f"Loaded {len(tools)} built-in tools: {tool_names}")
 
128
  Based on codex-rs/core/src/tools/router.rs
129
  """
130
 
131
+ def __init__(self, mcp_servers: dict[str, MCPServerConfig], hf_token: str | None = None, local_mode: bool = False):
132
  self.tools: dict[str, ToolSpec] = {}
133
  self.mcp_servers: dict[str, dict[str, Any]] = {}
134
 
135
+ for tool in create_builtin_tools(local_mode=local_mode):
136
  self.register_tool(tool)
137
 
138
  self.mcp_client: Client | None = None
 
273
  # ============================================================================
274
 
275
 
276
+ def create_builtin_tools(local_mode: bool = False) -> list[ToolSpec]:
277
  """Create built-in tool specifications"""
278
  # in order of importance
279
  tools = [
 
350
  ),
351
  ]
352
 
353
+ # Sandbox or local tools (highest priority)
354
+ if local_mode:
355
+ from agent.tools.local_tools import get_local_tools
356
+ tools = get_local_tools() + tools
357
+ else:
358
+ tools = get_sandbox_tools() + tools
359
 
360
  tool_names = ", ".join([t.name for t in tools])
361
  logger.info(f"Loaded {len(tools)} built-in tools: {tool_names}")
agent/main.py CHANGED
@@ -5,6 +5,7 @@ Interactive CLI chat with the agent
5
  import asyncio
6
  import json
7
  import os
 
8
  from dataclasses import dataclass
9
  from pathlib import Path
10
  from typing import Any, Optional
@@ -30,6 +31,15 @@ from agent.utils.terminal_display import (
30
 
31
  litellm.drop_params = True
32
 
 
 
 
 
 
 
 
 
 
33
 
34
  def _safe_get_args(arguments: dict) -> dict:
35
  """Safely extract args dict from arguments, handling cases where LLM passes string."""
@@ -40,6 +50,20 @@ def _safe_get_args(arguments: dict) -> dict:
40
  return args if isinstance(args, dict) else {}
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  @dataclass
45
  class Operation:
@@ -102,6 +126,22 @@ async def event_listener(
102
  if plan_display:
103
  print(plan_display)
104
  turn_complete_event.set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  elif event.event_type == "error":
106
  error = (
107
  event.data.get("error", "Unknown error")
@@ -117,7 +157,7 @@ async def event_listener(
117
  elif event.event_type == "compacted":
118
  old_tokens = event.data.get("old_tokens", 0) if event.data else 0
119
  new_tokens = event.data.get("new_tokens", 0) if event.data else 0
120
- print(f"Compacted context: {old_tokens} β†’ {new_tokens} tokens")
121
  elif event.event_type == "approval_required":
122
  # Handle batch approval format
123
  tools_data = event.data.get("tools", []) if event.data else []
@@ -133,7 +173,7 @@ async def event_listener(
133
  }
134
  for t in tools_data
135
  ]
136
- print(f"\n⚑ YOLO MODE: Auto-approving {count} item(s)")
137
  submission_id[0] += 1
138
  approval_submission = Submission(
139
  id=f"approval_{submission_id[0]}",
@@ -377,7 +417,7 @@ async def event_listener(
377
  if response == "yolo":
378
  config.yolo_mode = True
379
  print(
380
- "⚑ YOLO MODE ACTIVATED - Auto-approving all future tool calls"
381
  )
382
  # Auto-approve this item and all remaining
383
  approvals.append(
@@ -434,6 +474,93 @@ async def get_user_input(prompt_session: PromptSession) -> str:
434
  return await prompt_session.prompt_async(HTML("\n<b><cyan>></cyan></b> "))
435
 
436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  async def main():
438
  """Interactive chat with the agent"""
439
  from agent.utils.terminal_display import Colors
@@ -442,21 +569,28 @@ async def main():
442
  os.system("clear" if os.name != "nt" else "cls")
443
 
444
  banner = r"""
445
- _ _ _ _____ _ _
446
- | | | |_ _ __ _ __ _(_)_ __ __ _ | ___|_ _ ___ ___ / \ __ _ ___ _ __ | |_
447
  | |_| | | | |/ _` |/ _` | | '_ \ / _` | | |_ / _` |/ __/ _ \ / _ \ / _` |/ _ \ '_ \| __|
448
- | _ | |_| | (_| | (_| | | | | | (_| | | _| (_| | (_| __/ / ___ \ (_| | __/ | | | |_
449
  |_| |_|\__,_|\__, |\__, |_|_| |_|\__, | |_| \__,_|\___\___| /_/ \_\__, |\___|_| |_|\__|
450
  |___/ |___/ |___/ |___/
451
  """
452
 
453
  print(format_separator())
454
  print(f"{Colors.YELLOW} {banner}{Colors.RESET}")
455
- print("Type your messages below. Type 'exit', 'quit', or '/quit' to end.\n")
456
  print(format_separator())
457
  # Wait for agent to initialize
458
  print("Initializing agent...")
459
 
 
 
 
 
 
 
 
460
  # Create queues for communication
461
  submission_queue = asyncio.Queue()
462
  event_queue = asyncio.Queue()
@@ -470,19 +604,24 @@ async def main():
470
  config_path = Path(__file__).parent.parent / "configs" / "main_agent_config.json"
471
  config = load_config(config_path)
472
 
473
- # Create tool router
474
  print(f"Loading MCP servers: {', '.join(config.mcpServers.keys())}")
475
- tool_router = ToolRouter(config.mcpServers)
476
 
477
  # Create prompt session for input
478
  prompt_session = PromptSession()
479
 
 
 
 
480
  agent_task = asyncio.create_task(
481
  submission_loop(
482
  submission_queue,
483
  event_queue,
484
  config=config,
485
  tool_router=tool_router,
 
 
486
  )
487
  )
488
 
@@ -500,12 +639,16 @@ async def main():
500
 
501
  await ready_event.wait()
502
 
503
- submission_id = 0
 
504
 
505
  try:
506
  while True:
507
- # Wait for previous turn to complete
508
- await turn_complete_event.wait()
 
 
 
509
  turn_complete_event.clear()
510
 
511
  # Get user input
@@ -513,6 +656,21 @@ async def main():
513
  user_input = await get_user_input(prompt_session)
514
  except EOFError:
515
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
 
517
  # Check for exit commands
518
  if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
@@ -523,35 +681,50 @@ async def main():
523
  turn_complete_event.set()
524
  continue
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  # Submit to agent
527
- submission_id += 1
528
  submission = Submission(
529
- id=f"sub_{submission_id}",
530
  operation=Operation(
531
  op_type=OpType.USER_INPUT, data={"text": user_input}
532
  ),
533
  )
534
- # print(f"Main submitting: {submission.operation.op_type}")
535
  await submission_queue.put(submission)
536
 
537
  except KeyboardInterrupt:
538
  print("\n\nInterrupted by user")
539
 
540
  # Shutdown
541
- print("\nπŸ›‘ Shutting down agent...")
542
  shutdown_submission = Submission(
543
  id="sub_shutdown", operation=Operation(op_type=OpType.SHUTDOWN)
544
  )
545
  await submission_queue.put(shutdown_submission)
546
 
547
- await asyncio.wait_for(agent_task, timeout=5.0)
 
 
 
548
  listener_task.cancel()
549
 
550
- print("✨ Goodbye!\n")
551
 
552
 
553
  if __name__ == "__main__":
554
  try:
555
  asyncio.run(main())
556
  except KeyboardInterrupt:
557
- print("\n\n✨ Goodbye!")
 
5
  import asyncio
6
  import json
7
  import os
8
+ import time
9
  from dataclasses import dataclass
10
  from pathlib import Path
11
  from typing import Any, Optional
 
31
 
32
  litellm.drop_params = True
33
 
34
+ # ── Available models (mirrors backend/routes/agent.py) ──────────────────
35
+ AVAILABLE_MODELS = [
36
+ {"id": "anthropic/claude-opus-4-6", "label": "Claude Opus 4.6"},
37
+ {"id": "huggingface/fireworks-ai/MiniMaxAI/MiniMax-M2.5", "label": "MiniMax M2.5"},
38
+ {"id": "huggingface/novita/moonshotai/kimi-k2.5", "label": "Kimi K2.5"},
39
+ {"id": "huggingface/novita/zai-org/glm-5", "label": "GLM 5"},
40
+ ]
41
+ VALID_MODEL_IDS = {m["id"] for m in AVAILABLE_MODELS}
42
+
43
 
44
  def _safe_get_args(arguments: dict) -> dict:
45
  """Safely extract args dict from arguments, handling cases where LLM passes string."""
 
50
  return args if isinstance(args, dict) else {}
51
 
52
 
53
+ def _get_hf_token() -> str | None:
54
+ """Get HF token from environment or huggingface_hub login."""
55
+ token = os.environ.get("HF_TOKEN")
56
+ if token:
57
+ return token
58
+ try:
59
+ from huggingface_hub import HfApi
60
+ api = HfApi()
61
+ token = api.token
62
+ if token:
63
+ return token
64
+ except Exception:
65
+ pass
66
+ return None
67
 
68
  @dataclass
69
  class Operation:
 
126
  if plan_display:
127
  print(plan_display)
128
  turn_complete_event.set()
129
+ elif event.event_type == "interrupted":
130
+ print("\n(interrupted)")
131
+ turn_complete_event.set()
132
+ elif event.event_type == "undo_complete":
133
+ print("Undo complete.")
134
+ turn_complete_event.set()
135
+ elif event.event_type == "tool_log":
136
+ tool = event.data.get("tool", "") if event.data else ""
137
+ log = event.data.get("log", "") if event.data else ""
138
+ if log:
139
+ print(f" [{tool}] {log}")
140
+ elif event.event_type == "tool_state_change":
141
+ tool = event.data.get("tool", "") if event.data else ""
142
+ state = event.data.get("state", "") if event.data else ""
143
+ if state in ("approved", "rejected", "running"):
144
+ print(f" {tool}: {state}")
145
  elif event.event_type == "error":
146
  error = (
147
  event.data.get("error", "Unknown error")
 
157
  elif event.event_type == "compacted":
158
  old_tokens = event.data.get("old_tokens", 0) if event.data else 0
159
  new_tokens = event.data.get("new_tokens", 0) if event.data else 0
160
+ print(f"Compacted context: {old_tokens} -> {new_tokens} tokens")
161
  elif event.event_type == "approval_required":
162
  # Handle batch approval format
163
  tools_data = event.data.get("tools", []) if event.data else []
 
173
  }
174
  for t in tools_data
175
  ]
176
+ print(f"\n YOLO MODE: Auto-approving {count} item(s)")
177
  submission_id[0] += 1
178
  approval_submission = Submission(
179
  id=f"approval_{submission_id[0]}",
 
417
  if response == "yolo":
418
  config.yolo_mode = True
419
  print(
420
+ "YOLO MODE ACTIVATED - Auto-approving all future tool calls"
421
  )
422
  # Auto-approve this item and all remaining
423
  approvals.append(
 
474
  return await prompt_session.prompt_async(HTML("\n<b><cyan>></cyan></b> "))
475
 
476
 
477
+ # ── Slash command helpers ────────────────────────────────────────────────
478
+
479
+ HELP_TEXT = """\
480
+ Commands:
481
+ /help Show this help
482
+ /undo Undo last turn
483
+ /compact Compact context window
484
+ /model [id] Show available models or switch model
485
+ /yolo Toggle auto-approve mode
486
+ /status Show current model, turn count
487
+ /quit, /exit Exit the CLI
488
+ """
489
+
490
+
491
+ def _handle_slash_command(
492
+ cmd: str,
493
+ config,
494
+ session_holder: list,
495
+ submission_queue: asyncio.Queue,
496
+ submission_id: list[int],
497
+ ) -> Submission | None:
498
+ """
499
+ Handle a slash command. Returns a Submission to enqueue, or None if
500
+ the command was handled locally (caller should set turn_complete_event).
501
+ """
502
+ parts = cmd.strip().split(None, 1)
503
+ command = parts[0].lower()
504
+ arg = parts[1].strip() if len(parts) > 1 else ""
505
+
506
+ if command == "/help":
507
+ print(HELP_TEXT)
508
+ return None
509
+
510
+ if command == "/undo":
511
+ submission_id[0] += 1
512
+ return Submission(
513
+ id=f"sub_{submission_id[0]}",
514
+ operation=Operation(op_type=OpType.UNDO),
515
+ )
516
+
517
+ if command == "/compact":
518
+ submission_id[0] += 1
519
+ return Submission(
520
+ id=f"sub_{submission_id[0]}",
521
+ operation=Operation(op_type=OpType.COMPACT),
522
+ )
523
+
524
+ if command == "/model":
525
+ if not arg:
526
+ print("Available models:")
527
+ session = session_holder[0] if session_holder else None
528
+ current = config.model_name if config else ""
529
+ for m in AVAILABLE_MODELS:
530
+ marker = " <-- current" if m["id"] == current else ""
531
+ print(f" {m['id']} ({m['label']}){marker}")
532
+ return None
533
+ if arg not in VALID_MODEL_IDS:
534
+ print(f"Unknown model: {arg}")
535
+ print(f"Valid: {', '.join(VALID_MODEL_IDS)}")
536
+ return None
537
+ session = session_holder[0] if session_holder else None
538
+ if session:
539
+ session.update_model(arg)
540
+ print(f"Model switched to {arg}")
541
+ else:
542
+ config.model_name = arg
543
+ print(f"Model set to {arg} (session not started yet)")
544
+ return None
545
+
546
+ if command == "/yolo":
547
+ config.yolo_mode = not config.yolo_mode
548
+ state = "ON" if config.yolo_mode else "OFF"
549
+ print(f"YOLO mode: {state}")
550
+ return None
551
+
552
+ if command == "/status":
553
+ session = session_holder[0] if session_holder else None
554
+ print(f"Model: {config.model_name}")
555
+ if session:
556
+ print(f"Turns: {session.turn_count}")
557
+ print(f"Context items: {len(session.context_manager.items)}")
558
+ return None
559
+
560
+ print(f"Unknown command: {command}. Type /help for available commands.")
561
+ return None
562
+
563
+
564
  async def main():
565
  """Interactive chat with the agent"""
566
  from agent.utils.terminal_display import Colors
 
569
  os.system("clear" if os.name != "nt" else "cls")
570
 
571
  banner = r"""
572
+ _ _ _ _____ _ _
573
+ | | | |_ _ __ _ __ _(_)_ __ __ _ | ___|_ _ ___ ___ / \ __ _ ___ _ __ | |_
574
  | |_| | | | |/ _` |/ _` | | '_ \ / _` | | |_ / _` |/ __/ _ \ / _ \ / _` |/ _ \ '_ \| __|
575
+ | _ | |_| | (_| | (_| | | | | | (_| | | _| (_| | (_| __/ / ___ \ (_| | __/ | | | |_
576
  |_| |_|\__,_|\__, |\__, |_|_| |_|\__, | |_| \__,_|\___\___| /_/ \_\__, |\___|_| |_|\__|
577
  |___/ |___/ |___/ |___/
578
  """
579
 
580
  print(format_separator())
581
  print(f"{Colors.YELLOW} {banner}{Colors.RESET}")
582
+ print("Type your messages below. Type /help for commands, /quit to exit.\n")
583
  print(format_separator())
584
  # Wait for agent to initialize
585
  print("Initializing agent...")
586
 
587
+ # HF token
588
+ hf_token = _get_hf_token()
589
+ if hf_token:
590
+ print("HF token loaded")
591
+ else:
592
+ print("Warning: No HF token found. Set HF_TOKEN or run `huggingface-cli login`.")
593
+
594
  # Create queues for communication
595
  submission_queue = asyncio.Queue()
596
  event_queue = asyncio.Queue()
 
604
  config_path = Path(__file__).parent.parent / "configs" / "main_agent_config.json"
605
  config = load_config(config_path)
606
 
607
+ # Create tool router with local mode
608
  print(f"Loading MCP servers: {', '.join(config.mcpServers.keys())}")
609
+ tool_router = ToolRouter(config.mcpServers, hf_token=hf_token, local_mode=True)
610
 
611
  # Create prompt session for input
612
  prompt_session = PromptSession()
613
 
614
+ # Session holder for interrupt/model/status access
615
+ session_holder = [None]
616
+
617
  agent_task = asyncio.create_task(
618
  submission_loop(
619
  submission_queue,
620
  event_queue,
621
  config=config,
622
  tool_router=tool_router,
623
+ session_holder=session_holder,
624
+ hf_token=hf_token,
625
  )
626
  )
627
 
 
639
 
640
  await ready_event.wait()
641
 
642
+ submission_id = [0]
643
+ last_interrupt_time = 0.0
644
 
645
  try:
646
  while True:
647
+ # Wait for previous turn to complete, with interrupt support
648
+ try:
649
+ await turn_complete_event.wait()
650
+ except asyncio.CancelledError:
651
+ break
652
  turn_complete_event.clear()
653
 
654
  # Get user input
 
656
  user_input = await get_user_input(prompt_session)
657
  except EOFError:
658
  break
659
+ except KeyboardInterrupt:
660
+ now = time.monotonic()
661
+ if now - last_interrupt_time < 3.0:
662
+ print("\nDouble Ctrl+C, exiting...")
663
+ break
664
+ last_interrupt_time = now
665
+ # If agent is busy, cancel it
666
+ session = session_holder[0]
667
+ if session and not turn_complete_event.is_set():
668
+ session.cancel()
669
+ print("\nInterrupting agent...")
670
+ else:
671
+ print("\n(Ctrl+C again within 3s to exit)")
672
+ turn_complete_event.set()
673
+ continue
674
 
675
  # Check for exit commands
676
  if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
 
681
  turn_complete_event.set()
682
  continue
683
 
684
+ # Handle slash commands
685
+ if user_input.strip().startswith("/"):
686
+ sub = _handle_slash_command(
687
+ user_input.strip(), config, session_holder, submission_queue, submission_id
688
+ )
689
+ if sub is None:
690
+ # Command handled locally, loop back for input
691
+ turn_complete_event.set()
692
+ continue
693
+ else:
694
+ await submission_queue.put(sub)
695
+ continue
696
+
697
  # Submit to agent
698
+ submission_id[0] += 1
699
  submission = Submission(
700
+ id=f"sub_{submission_id[0]}",
701
  operation=Operation(
702
  op_type=OpType.USER_INPUT, data={"text": user_input}
703
  ),
704
  )
 
705
  await submission_queue.put(submission)
706
 
707
  except KeyboardInterrupt:
708
  print("\n\nInterrupted by user")
709
 
710
  # Shutdown
711
+ print("\nShutting down agent...")
712
  shutdown_submission = Submission(
713
  id="sub_shutdown", operation=Operation(op_type=OpType.SHUTDOWN)
714
  )
715
  await submission_queue.put(shutdown_submission)
716
 
717
+ try:
718
+ await asyncio.wait_for(agent_task, timeout=5.0)
719
+ except asyncio.TimeoutError:
720
+ agent_task.cancel()
721
  listener_task.cancel()
722
 
723
+ print("Goodbye!\n")
724
 
725
 
726
  if __name__ == "__main__":
727
  try:
728
  asyncio.run(main())
729
  except KeyboardInterrupt:
730
+ print("\n\nGoodbye!")
agent/tools/local_tools.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local tool implementations β€” bash/read/write/edit running on the user's machine.
3
+
4
+ Drop-in replacement for sandbox tools when running in CLI (local) mode.
5
+ Same tool specs (names, parameters) but handlers execute locally via
6
+ subprocess/pathlib instead of going through a remote sandbox.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agent.tools.sandbox_client import Sandbox
16
+
17
+ MAX_OUTPUT_CHARS = 30_000
18
+ MAX_LINE_LENGTH = 2000
19
+ DEFAULT_READ_LINES = 2000
20
+ DEFAULT_TIMEOUT = 120
21
+ MAX_TIMEOUT = 600
22
+
23
+
24
+ # ── Handlers ────────────────────────────────────────────────────────────
25
+
26
+ async def _bash_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
27
+ command = args.get("command", "")
28
+ if not command:
29
+ return "No command provided.", False
30
+ work_dir = args.get("work_dir", ".")
31
+ timeout = min(args.get("timeout") or DEFAULT_TIMEOUT, MAX_TIMEOUT)
32
+ try:
33
+ result = subprocess.run(
34
+ command,
35
+ shell=True,
36
+ capture_output=True,
37
+ text=True,
38
+ cwd=work_dir,
39
+ timeout=timeout,
40
+ )
41
+ output = result.stdout + result.stderr
42
+ if len(output) > MAX_OUTPUT_CHARS:
43
+ output = output[:MAX_OUTPUT_CHARS] + "\n... (output truncated)"
44
+ if not output.strip():
45
+ output = "(no output)"
46
+ return output, result.returncode == 0
47
+ except subprocess.TimeoutExpired:
48
+ return f"Command timed out after {timeout}s.", False
49
+ except Exception as e:
50
+ return f"bash error: {e}", False
51
+
52
+
53
+ async def _read_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
54
+ file_path = args.get("path", "")
55
+ if not file_path:
56
+ return "No path provided.", False
57
+ p = Path(file_path)
58
+ if not p.exists():
59
+ return f"File not found: {file_path}", False
60
+ if p.is_dir():
61
+ return "Cannot read a directory. Use bash with 'ls' instead.", False
62
+ try:
63
+ lines = p.read_text().splitlines()
64
+ except Exception as e:
65
+ return f"read error: {e}", False
66
+
67
+ offset = max((args.get("offset") or 1), 1)
68
+ limit = args.get("limit") or DEFAULT_READ_LINES
69
+
70
+ selected = lines[offset - 1 : offset - 1 + limit]
71
+ numbered = []
72
+ for i, line in enumerate(selected, start=offset):
73
+ if len(line) > MAX_LINE_LENGTH:
74
+ line = line[:MAX_LINE_LENGTH] + "..."
75
+ numbered.append(f"{i:>6}\t{line}")
76
+ return "\n".join(numbered), True
77
+
78
+
79
+ async def _write_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
80
+ file_path = args.get("path", "")
81
+ content = args.get("content", "")
82
+ if not file_path:
83
+ return "No path provided.", False
84
+ p = Path(file_path)
85
+ try:
86
+ p.parent.mkdir(parents=True, exist_ok=True)
87
+ p.write_text(content)
88
+ return f"Wrote {len(content)} bytes to {file_path}", True
89
+ except Exception as e:
90
+ return f"write error: {e}", False
91
+
92
+
93
+ async def _edit_handler(args: dict[str, Any], **_kw) -> tuple[str, bool]:
94
+ file_path = args.get("path", "")
95
+ old_str = args.get("old_str", "")
96
+ new_str = args.get("new_str", "")
97
+ replace_all = args.get("replace_all", False)
98
+
99
+ if not file_path:
100
+ return "No path provided.", False
101
+ if old_str == new_str:
102
+ return "old_str and new_str must differ.", False
103
+
104
+ p = Path(file_path)
105
+ if not p.exists():
106
+ return f"File not found: {file_path}", False
107
+
108
+ try:
109
+ text = p.read_text()
110
+ except Exception as e:
111
+ return f"edit read error: {e}", False
112
+
113
+ count = text.count(old_str)
114
+ if count == 0:
115
+ return "old_str not found in file.", False
116
+ if count > 1 and not replace_all:
117
+ return (
118
+ f"old_str appears {count} times. Use replace_all=true to replace all, "
119
+ "or provide a more specific old_str."
120
+ ), False
121
+
122
+ new_text = text.replace(old_str, new_str) if replace_all else text.replace(old_str, new_str, 1)
123
+ try:
124
+ p.write_text(new_text)
125
+ except Exception as e:
126
+ return f"edit write error: {e}", False
127
+
128
+ replacements = count if replace_all else 1
129
+ return f"Edited {file_path} ({replacements} replacement{'s' if replacements > 1 else ''})", True
130
+
131
+
132
+ # ── Public API ──────────────────────────────────────────────────────────
133
+
134
+ _HANDLERS = {
135
+ "bash": _bash_handler,
136
+ "read": _read_handler,
137
+ "write": _write_handler,
138
+ "edit": _edit_handler,
139
+ }
140
+
141
+
142
+ def get_local_tools():
143
+ """Return local ToolSpecs for bash/read/write/edit (no sandbox_create)."""
144
+ from agent.core.tools import ToolSpec
145
+
146
+ tools = []
147
+ for name, spec in Sandbox.TOOLS.items():
148
+ handler = _HANDLERS.get(name)
149
+ if handler is None:
150
+ continue
151
+ tools.append(
152
+ ToolSpec(
153
+ name=name,
154
+ description=spec["description"],
155
+ parameters=spec["parameters"],
156
+ handler=handler,
157
+ )
158
+ )
159
+ return tools