emdash-core 0.1.33__py3-none-any.whl → 0.1.60__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 (67) hide show
  1. emdash_core/agent/agents.py +93 -23
  2. emdash_core/agent/background.py +481 -0
  3. emdash_core/agent/hooks.py +419 -0
  4. emdash_core/agent/inprocess_subagent.py +114 -10
  5. emdash_core/agent/mcp/config.py +78 -2
  6. emdash_core/agent/prompts/main_agent.py +88 -1
  7. emdash_core/agent/prompts/plan_mode.py +65 -44
  8. emdash_core/agent/prompts/subagents.py +96 -8
  9. emdash_core/agent/prompts/workflow.py +215 -50
  10. emdash_core/agent/providers/models.py +1 -1
  11. emdash_core/agent/providers/openai_provider.py +10 -0
  12. emdash_core/agent/research/researcher.py +154 -45
  13. emdash_core/agent/runner/agent_runner.py +157 -19
  14. emdash_core/agent/runner/context.py +28 -9
  15. emdash_core/agent/runner/sdk_runner.py +29 -2
  16. emdash_core/agent/skills.py +81 -1
  17. emdash_core/agent/toolkit.py +87 -11
  18. emdash_core/agent/toolkits/__init__.py +117 -18
  19. emdash_core/agent/toolkits/base.py +87 -2
  20. emdash_core/agent/toolkits/explore.py +18 -0
  21. emdash_core/agent/toolkits/plan.py +18 -0
  22. emdash_core/agent/tools/__init__.py +2 -0
  23. emdash_core/agent/tools/coding.py +344 -52
  24. emdash_core/agent/tools/lsp.py +361 -0
  25. emdash_core/agent/tools/skill.py +21 -1
  26. emdash_core/agent/tools/task.py +27 -23
  27. emdash_core/agent/tools/task_output.py +262 -32
  28. emdash_core/agent/verifier/__init__.py +11 -0
  29. emdash_core/agent/verifier/manager.py +295 -0
  30. emdash_core/agent/verifier/models.py +97 -0
  31. emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
  32. emdash_core/api/agent.py +451 -5
  33. emdash_core/api/research.py +3 -3
  34. emdash_core/api/router.py +0 -4
  35. emdash_core/context/longevity.py +197 -0
  36. emdash_core/context/providers/explored_areas.py +83 -39
  37. emdash_core/context/reranker.py +35 -144
  38. emdash_core/context/simple_reranker.py +500 -0
  39. emdash_core/context/tool_relevance.py +84 -0
  40. emdash_core/core/config.py +8 -0
  41. emdash_core/graph/__init__.py +8 -1
  42. emdash_core/graph/connection.py +24 -3
  43. emdash_core/graph/writer.py +7 -1
  44. emdash_core/ingestion/repository.py +17 -198
  45. emdash_core/models/agent.py +14 -0
  46. emdash_core/server.py +1 -6
  47. emdash_core/sse/stream.py +16 -1
  48. emdash_core/utils/__init__.py +0 -2
  49. emdash_core/utils/git.py +103 -0
  50. emdash_core/utils/image.py +147 -160
  51. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/METADATA +7 -5
  52. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/RECORD +54 -58
  53. emdash_core/api/swarm.py +0 -223
  54. emdash_core/db/__init__.py +0 -67
  55. emdash_core/db/auth.py +0 -134
  56. emdash_core/db/models.py +0 -91
  57. emdash_core/db/provider.py +0 -222
  58. emdash_core/db/providers/__init__.py +0 -5
  59. emdash_core/db/providers/supabase.py +0 -452
  60. emdash_core/swarm/__init__.py +0 -17
  61. emdash_core/swarm/merge_agent.py +0 -383
  62. emdash_core/swarm/session_manager.py +0 -274
  63. emdash_core/swarm/swarm_runner.py +0 -226
  64. emdash_core/swarm/task_definition.py +0 -137
  65. emdash_core/swarm/worker_spawner.py +0 -319
  66. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
  67. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
@@ -11,6 +11,7 @@ from typing import Any, Optional
11
11
 
12
12
  from ...utils.logger import log
13
13
  from ...core.exceptions import ContextLengthError
14
+ from ...core.config import get_config
14
15
  from ..toolkit import AgentToolkit
15
16
  from ..events import AgentEventEmitter, NullEmitter
16
17
  from ..providers import get_provider
@@ -30,8 +31,10 @@ from .context import (
30
31
  get_context_breakdown,
31
32
  maybe_compact_context,
32
33
  emit_context_frame,
34
+ get_reranked_context,
33
35
  )
34
36
  from .plan import PlanMixin
37
+ from ..background import BackgroundTaskManager
35
38
 
36
39
 
37
40
  class AgentRunner(PlanMixin):
@@ -107,6 +110,14 @@ class AgentRunner(PlanMixin):
107
110
  self._tools_used_this_run: set[str] = set()
108
111
  # Plan approval state (from PlanMixin)
109
112
  self._pending_plan: Optional[dict] = None
113
+ # Callback for autosave after each iteration (set by API layer)
114
+ self._on_iteration_callback: Optional[callable] = None
115
+ # Context frame injection flag
116
+ self._inject_context_frame = os.getenv("EMDASH_INJECT_CONTEXT_FRAME", "").lower() in ("1", "true", "yes")
117
+ # Persistent thread pool executor for parallel tool execution
118
+ config = get_config()
119
+ self._tool_parallel_workers = config.agent.tool_parallel_workers
120
+ self._tool_executor: Optional[ThreadPoolExecutor] = None
110
121
 
111
122
  def _get_default_plan_file_path(self) -> str:
112
123
  """Get the default plan file path based on repo root.
@@ -141,9 +152,101 @@ class AgentRunner(PlanMixin):
141
152
  task_list = "\n".join(lines)
142
153
  return f"<todo-state>\n{header}\n{task_list}\n</todo-state>"
143
154
 
155
+ def _check_background_notifications(self) -> list[str]:
156
+ """Check for completed background tasks and format notifications.
157
+
158
+ Returns:
159
+ List of notification messages to inject into context
160
+ """
161
+ try:
162
+ manager = BackgroundTaskManager.get_instance()
163
+ completed_tasks = manager.get_pending_notifications()
164
+
165
+ notifications = []
166
+ for task in completed_tasks:
167
+ msg = manager.format_notification(task)
168
+ notifications.append(msg)
169
+ log.info(f"Background task {task.task_id} notification ready")
170
+
171
+ return notifications
172
+ except Exception as e:
173
+ log.warning(f"Failed to check background notifications: {e}")
174
+ return []
175
+
176
+ def _format_context_reminder(self) -> str:
177
+ """Format reranked context items as XML reminder for injection.
178
+
179
+ Only called when EMDASH_INJECT_CONTEXT_FRAME is enabled.
180
+
181
+ Returns:
182
+ Formatted context reminder string, or empty if no context
183
+ """
184
+ if not self._current_query:
185
+ return ""
186
+
187
+ reading = get_reranked_context(self.toolkit, self._current_query)
188
+ items = reading.get("items", [])
189
+
190
+ if not items:
191
+ return ""
192
+
193
+ lines = [
194
+ "<context-frame>",
195
+ f"Relevant context for query: {self._current_query[:100]}",
196
+ f"Found {len(items)} relevant items (ranked by relevance score):",
197
+ "",
198
+ ]
199
+
200
+ for item in items[:15]: # Top 15 items
201
+ name = item.get("name", "?")
202
+ item_type = item.get("type", "?")
203
+ score = item.get("score")
204
+ file_path = item.get("file", "")
205
+ description = item.get("description", "")
206
+
207
+ score_str = f" (score: {score:.3f})" if score is not None else ""
208
+ file_str = f" in {file_path}" if file_path else ""
209
+
210
+ lines.append(f" - [{item_type}] {name}{score_str}{file_str}")
211
+ if description:
212
+ lines.append(f" {description[:150]}")
213
+
214
+ lines.append("</context-frame>")
215
+ return "\n".join(lines)
216
+
217
+ def _get_tool_executor(self) -> ThreadPoolExecutor:
218
+ """Get the persistent thread pool executor, creating it if needed.
219
+
220
+ Uses lazy initialization to avoid creating threads until actually needed.
221
+ """
222
+ if self._tool_executor is None:
223
+ self._tool_executor = ThreadPoolExecutor(
224
+ max_workers=self._tool_parallel_workers,
225
+ thread_name_prefix="tool-exec-"
226
+ )
227
+ return self._tool_executor
228
+
229
+ def close(self) -> None:
230
+ """Clean up resources, including the thread pool executor."""
231
+ if self._tool_executor is not None:
232
+ self._tool_executor.shutdown(wait=False)
233
+ self._tool_executor = None
234
+
235
+ def __enter__(self):
236
+ """Support context manager protocol."""
237
+ return self
238
+
239
+ def __exit__(self, exc_type, exc_val, exc_tb):
240
+ """Clean up on exit from context manager."""
241
+ self.close()
242
+ return False
243
+
144
244
  def _execute_tools_parallel(self, parsed_calls: list) -> list:
145
245
  """Execute multiple tool calls in parallel using a thread pool.
146
246
 
247
+ Uses a persistent thread pool executor for better performance by avoiding
248
+ thread creation/destruction overhead on each batch of tool calls.
249
+
147
250
  Args:
148
251
  parsed_calls: List of (tool_call, args) tuples
149
252
 
@@ -164,14 +267,14 @@ class AgentRunner(PlanMixin):
164
267
  from ..tools.base import ToolResult
165
268
  return (tool_call, args, ToolResult.error_result(str(e)))
166
269
 
167
- # Execute in parallel with up to 3 workers
270
+ # Execute in parallel using persistent executor
271
+ executor = self._get_tool_executor()
168
272
  results: list = [None] * len(parsed_calls)
169
- with ThreadPoolExecutor(max_workers=3) as executor:
170
- futures = {executor.submit(execute_one, item): i for i, item in enumerate(parsed_calls)}
171
- # Collect results maintaining order
172
- for future in as_completed(futures):
173
- idx = futures[future]
174
- results[idx] = future.result()
273
+ futures = {executor.submit(execute_one, item): i for i, item in enumerate(parsed_calls)}
274
+ # Collect results maintaining order
275
+ for future in as_completed(futures):
276
+ idx = futures[future]
277
+ results[idx] = future.result()
175
278
 
176
279
  # Emit tool result events for all calls
177
280
  for tool_call, args, result in results:
@@ -207,24 +310,27 @@ class AgentRunner(PlanMixin):
207
310
  from ..tools.modes import ModeState
208
311
  ModeState.get_instance().reset_cycle()
209
312
 
210
- # Build user message
313
+ # Build user message content
211
314
  if context:
212
- user_message = {
213
- "role": "user",
214
- "content": f"Context:\n{context}\n\nQuestion: {query}",
215
- }
315
+ text_content = f"Context:\n{context}\n\nQuestion: {query}"
216
316
  else:
217
- user_message = {
218
- "role": "user",
219
- "content": query,
220
- }
317
+ text_content = query
318
+
319
+ # Format content with images if provided
320
+ if images:
321
+ content = self.provider.format_content_with_images(text_content, images)
322
+ else:
323
+ content = text_content
324
+
325
+ user_message = {
326
+ "role": "user",
327
+ "content": content,
328
+ }
221
329
 
222
330
  # Save user message to history BEFORE running (so it's preserved even if interrupted)
223
331
  self._messages.append(user_message)
224
332
  messages = list(self._messages) # Copy for the loop
225
333
 
226
- # TODO: Handle images if provided
227
-
228
334
  # Get tool schemas
229
335
  tools = self.toolkit.get_all_schemas()
230
336
 
@@ -260,6 +366,16 @@ class AgentRunner(PlanMixin):
260
366
  max_retries = 3
261
367
 
262
368
  for iteration in range(self.max_iterations):
369
+ # Check for completed background tasks and inject notifications
370
+ bg_notifications = self._check_background_notifications()
371
+ for notification in bg_notifications:
372
+ messages.append({
373
+ "role": "user",
374
+ "content": notification,
375
+ })
376
+ # Emit event so UI can show notification
377
+ self.emitter.emit_assistant_text(f"[Background task completed - see notification]")
378
+
263
379
  # When approaching max iterations, ask agent to wrap up
264
380
  if iteration == self.max_iterations - 2:
265
381
  messages.append({
@@ -504,6 +620,15 @@ class AgentRunner(PlanMixin):
504
620
  "content": result_json,
505
621
  })
506
622
 
623
+ # Inject context frame reminder if enabled (append to last tool result)
624
+ if self._inject_context_frame and messages and messages[-1].get("role") == "tool":
625
+ context_reminder = self._format_context_reminder()
626
+ if context_reminder:
627
+ messages[-1]["content"] += f"\n\n{context_reminder}"
628
+
629
+ # Emit context frame after each iteration (for autosave and UI updates)
630
+ self._emit_context_frame(messages)
631
+
507
632
  # If a clarification question was asked, pause and wait for user input
508
633
  if needs_user_input:
509
634
  log.debug("Pausing agent loop - waiting for user input")
@@ -644,6 +769,13 @@ DO NOT output more text. Use a tool NOW.""",
644
769
  total_output_tokens=self._total_output_tokens,
645
770
  )
646
771
 
772
+ # Call iteration callback for autosave if set
773
+ if self._on_iteration_callback and messages:
774
+ try:
775
+ self._on_iteration_callback(messages)
776
+ except Exception as e:
777
+ log.debug(f"Iteration callback failed: {e}")
778
+
647
779
  def chat(self, message: str, images: Optional[list] = None) -> str:
648
780
  """Continue a conversation with a new message.
649
781
 
@@ -664,10 +796,16 @@ DO NOT output more text. Use a tool NOW.""",
664
796
  # Store query for reranking context frame
665
797
  self._current_query = message
666
798
 
799
+ # Format content with images if provided
800
+ if images:
801
+ content = self.provider.format_content_with_images(message, images)
802
+ else:
803
+ content = message
804
+
667
805
  # Add new user message to history
668
806
  self._messages.append({
669
807
  "role": "user",
670
- "content": message,
808
+ "content": content,
671
809
  })
672
810
 
673
811
  # Get tool schemas
@@ -4,6 +4,7 @@ This module contains functions for estimating, compacting, and managing
4
4
  conversation context during agent runs.
5
5
  """
6
6
 
7
+ import os
7
8
  from typing import Optional, TYPE_CHECKING
8
9
 
9
10
  from ...utils.logger import log
@@ -299,7 +300,7 @@ def get_reranked_context(
299
300
  # Get exploration steps for context extraction
300
301
  steps = toolkit.get_exploration_steps()
301
302
  if not steps:
302
- return {"item_count": 0, "items": []}
303
+ return {"item_count": 0, "items": [], "query": current_query, "debug": "no exploration steps"}
303
304
 
304
305
  # Use context service to extract context items from exploration
305
306
  service = ContextService(connection=toolkit.connection)
@@ -314,34 +315,52 @@ def get_reranked_context(
314
315
  # Get context items
315
316
  items = service.get_context_items(terminal_id)
316
317
  if not items:
317
- return {"item_count": 0, "items": []}
318
+ return {"item_count": 0, "items": [], "query": current_query, "debug": f"no items from service ({len(steps)} steps)"}
319
+
320
+ # Get max tokens from env (default 2000)
321
+ max_tokens = int(os.getenv("CONTEXT_FRAME_MAX_TOKENS", "2000"))
318
322
 
319
323
  # Rerank by query relevance
320
324
  if current_query:
321
325
  items = rerank_context_items(
322
326
  items,
323
327
  current_query,
324
- top_k=20,
328
+ top_k=50, # Get more candidates, then filter by tokens
325
329
  )
326
330
 
327
- # Convert to serializable format
331
+ # Convert to serializable format, limiting by token count
328
332
  result_items = []
329
- for item in items[:20]: # Limit to 20 items
330
- result_items.append({
333
+ total_tokens = 0
334
+ for item in items:
335
+ item_dict = {
331
336
  "name": item.qualified_name,
332
337
  "type": item.entity_type,
333
338
  "file": item.file_path,
334
339
  "score": round(item.score, 3) if hasattr(item, 'score') else None,
335
- })
340
+ "description": item.description[:200] if item.description else None,
341
+ "touch_count": item.touch_count,
342
+ "neighbors": item.neighbors[:5] if item.neighbors else [],
343
+ }
344
+ # Estimate tokens for this item (~4 chars per token)
345
+ item_chars = len(str(item_dict))
346
+ item_tokens = item_chars // 4
347
+
348
+ if total_tokens + item_tokens > max_tokens:
349
+ break
350
+
351
+ result_items.append(item_dict)
352
+ total_tokens += item_tokens
336
353
 
337
354
  return {
338
355
  "item_count": len(result_items),
339
356
  "items": result_items,
357
+ "query": current_query,
358
+ "total_tokens": total_tokens,
340
359
  }
341
360
 
342
361
  except Exception as e:
343
- log.debug(f"Failed to get reranked context: {e}")
344
- return {"item_count": 0, "items": []}
362
+ log.warning(f"Failed to get reranked context: {e}")
363
+ return {"item_count": 0, "items": [], "query": current_query, "debug": str(e)}
345
364
 
346
365
 
347
366
  def emit_context_frame(
@@ -184,11 +184,12 @@ class SDKAgentRunner:
184
184
  from ..events import EventType
185
185
  self.emitter.emit(getattr(EventType, event_type), data)
186
186
 
187
- async def run(self, prompt: str) -> AsyncIterator[dict]:
187
+ async def run(self, prompt: str, images: list = None) -> AsyncIterator[dict]:
188
188
  """Execute agent with SDK.
189
189
 
190
190
  Args:
191
191
  prompt: User prompt/task
192
+ images: Optional list of image dicts with 'data' (bytes) and 'format' keys
192
193
 
193
194
  Yields:
194
195
  Event dicts for UI streaming
@@ -201,6 +202,7 @@ class SDKAgentRunner:
201
202
  ToolResultBlock,
202
203
  ResultMessage,
203
204
  )
205
+ import base64
204
206
 
205
207
  options = self._get_options()
206
208
 
@@ -210,9 +212,34 @@ class SDKAgentRunner:
210
212
  "agent_name": "Emdash Code (SDK)",
211
213
  })
212
214
 
215
+ # Format prompt with images if provided
216
+ if images:
217
+ # Build content blocks for Claude SDK
218
+ content_blocks = []
219
+ for img in images:
220
+ img_data = img.get("data")
221
+ img_format = img.get("format", "png")
222
+ if isinstance(img_data, bytes):
223
+ encoded = base64.b64encode(img_data).decode("utf-8")
224
+ else:
225
+ encoded = img_data # Already base64 encoded
226
+ content_blocks.append({
227
+ "type": "image",
228
+ "source": {
229
+ "type": "base64",
230
+ "media_type": f"image/{img_format}",
231
+ "data": encoded,
232
+ }
233
+ })
234
+ content_blocks.append({"type": "text", "text": prompt})
235
+ query_content = content_blocks
236
+ log.info(f"SDK agent: sending {len(images)} images with prompt")
237
+ else:
238
+ query_content = prompt
239
+
213
240
  try:
214
241
  async with ClaudeSDKClient(options=options) as client:
215
- await client.query(prompt)
242
+ await client.query(query_content)
216
243
 
217
244
  async for message in client.receive_response():
218
245
  # Process message and yield events
@@ -16,6 +16,9 @@ from dataclasses import dataclass, field
16
16
  from pathlib import Path
17
17
  from typing import Optional
18
18
 
19
+ import os
20
+ import stat
21
+
19
22
  from ..utils.logger import log
20
23
 
21
24
 
@@ -24,6 +27,66 @@ def _get_builtin_skills_dir() -> Path:
24
27
  return Path(__file__).parent.parent / "skills"
25
28
 
26
29
 
30
+ def _discover_scripts(skill_dir: Path) -> list[Path]:
31
+ """Discover executable scripts in a skill directory.
32
+
33
+ Scripts are self-contained bash executables that can be run by the agent
34
+ to perform specific actions. They must be either:
35
+ - Files with .sh extension
36
+ - Files with executable permission and a shebang (#!/bin/bash, #!/usr/bin/env bash, etc.)
37
+
38
+ Args:
39
+ skill_dir: Path to the skill directory
40
+
41
+ Returns:
42
+ List of paths to executable scripts
43
+ """
44
+ scripts = []
45
+
46
+ if not skill_dir.exists() or not skill_dir.is_dir():
47
+ return scripts
48
+
49
+ # Files to skip (not scripts)
50
+ skip_files = {"SKILL.md", "skill.md", "README.md", "readme.md"}
51
+
52
+ for file_path in skill_dir.iterdir():
53
+ if not file_path.is_file():
54
+ continue
55
+
56
+ if file_path.name in skip_files:
57
+ continue
58
+
59
+ # Check if it's a .sh file
60
+ is_shell_script = file_path.suffix == ".sh"
61
+
62
+ # Check if it has a shebang
63
+ has_shebang = False
64
+ try:
65
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
66
+ first_line = f.readline().strip()
67
+ if first_line.startswith("#!"):
68
+ # Check for bash/sh shebang
69
+ if any(shell in first_line for shell in ["bash", "/sh", "python", "node", "ruby", "perl"]):
70
+ has_shebang = True
71
+ except (OSError, IOError):
72
+ continue
73
+
74
+ if is_shell_script or has_shebang:
75
+ # Ensure the file is executable
76
+ try:
77
+ current_mode = file_path.stat().st_mode
78
+ if not (current_mode & stat.S_IXUSR):
79
+ # Make it executable for the user
80
+ os.chmod(file_path, current_mode | stat.S_IXUSR)
81
+ log.debug(f"Made script executable: {file_path}")
82
+ except OSError as e:
83
+ log.warning(f"Could not make script executable: {file_path}: {e}")
84
+
85
+ scripts.append(file_path)
86
+
87
+ return sorted(scripts, key=lambda p: p.name)
88
+
89
+
27
90
  @dataclass
28
91
  class Skill:
29
92
  """A skill configuration loaded from SKILL.md.
@@ -35,6 +98,7 @@ class Skill:
35
98
  tools: List of tools this skill needs access to
36
99
  user_invocable: Whether skill can be invoked with /name
37
100
  file_path: Source file path
101
+ scripts: List of executable script paths in the skill directory
38
102
  _builtin: Whether this is a built-in skill bundled with emdash_core
39
103
  """
40
104
 
@@ -44,6 +108,7 @@ class Skill:
44
108
  tools: list[str] = field(default_factory=list)
45
109
  user_invocable: bool = False
46
110
  file_path: Optional[Path] = None
111
+ scripts: list[Path] = field(default_factory=list)
47
112
  _builtin: bool = False
48
113
 
49
114
 
@@ -166,6 +231,10 @@ class SkillRegistry:
166
231
  skill = _parse_skill_file(skill_file, skill_dir.name)
167
232
  if skill:
168
233
  skill._builtin = is_builtin # Mark as built-in or user-defined
234
+ # Discover scripts in the skill directory
235
+ skill.scripts = _discover_scripts(skill_dir)
236
+ if skill.scripts:
237
+ log.debug(f"Found {len(skill.scripts)} scripts in skill: {skill.name}")
169
238
  skills[skill.name] = skill
170
239
  self._skills[skill.name] = skill
171
240
  source = "built-in" if is_builtin else "user"
@@ -224,12 +293,23 @@ class SkillRegistry:
224
293
 
225
294
  for skill in self._skills.values():
226
295
  invocable = " (user-invocable: /{})".format(skill.name) if skill.user_invocable else ""
227
- lines.append(f"- **{skill.name}**: {skill.description}{invocable}")
296
+ scripts_note = f" [has {len(skill.scripts)} script(s)]" if skill.scripts else ""
297
+ lines.append(f"- **{skill.name}**: {skill.description}{invocable}{scripts_note}")
228
298
 
229
299
  lines.append("")
230
300
  lines.append("To activate a skill, use the `skill` tool with the skill name.")
231
301
  lines.append("")
232
302
 
303
+ # Add note about skill scripts if any skill has scripts
304
+ has_scripts = any(skill.scripts for skill in self._skills.values())
305
+ if has_scripts:
306
+ lines.append("### Skill Scripts")
307
+ lines.append("")
308
+ lines.append("Some skills include executable scripts that can be run using the Bash tool.")
309
+ lines.append("When you invoke a skill with scripts, the script paths will be provided.")
310
+ lines.append("Scripts are self-contained and can be executed directly.")
311
+ lines.append("")
312
+
233
313
  return "\n".join(lines)
234
314
 
235
315
 
@@ -1,9 +1,10 @@
1
1
  """Main AgentToolkit class for LLM agent graph exploration."""
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
  from typing import Optional
5
6
 
6
- from ..graph.connection import KuzuConnection, get_connection
7
+ from ..graph.connection import KuzuConnection, get_connection, KUZU_AVAILABLE
7
8
  from .tools.base import BaseTool, ToolResult, ToolCategory
8
9
  from .session import AgentSession
9
10
  from ..utils.logger import log
@@ -56,7 +57,20 @@ class AgentToolkit:
56
57
  save_spec_path: If provided, specs will be saved to this path.
57
58
  plan_file_path: Path to the plan file (only writable file in plan mode).
58
59
  """
59
- self.connection = connection or get_connection()
60
+ # Handle connection - Kuzu is optional
61
+ if connection is not None:
62
+ self.connection = connection
63
+ elif KUZU_AVAILABLE:
64
+ try:
65
+ self.connection = get_connection()
66
+ except Exception as e:
67
+ log.warning(f"Failed to connect to Kuzu database: {e}")
68
+ log.warning("Semantic search will be disabled. Other tools will work normally.")
69
+ self.connection = None
70
+ else:
71
+ log.info("Kuzu not installed - semantic search disabled. Install with: pip install kuzu")
72
+ self.connection = None
73
+
60
74
  self.session = AgentSession() if enable_session else None
61
75
  self._tools: dict[str, BaseTool] = {}
62
76
  self._mcp_manager = None
@@ -104,8 +118,10 @@ class AgentToolkit:
104
118
  )
105
119
 
106
120
  # Register search tools
107
- self.register_tool(SemanticSearchTool(self.connection))
108
- # self.register_tool(TextSearchTool(self.connection)) # Disabled due to DB locking issues
121
+ # SemanticSearchTool requires Kuzu database
122
+ if self.connection is not None:
123
+ self.register_tool(SemanticSearchTool(self.connection))
124
+ # These tools work without database connection
109
125
  self.register_tool(GrepTool(self.connection))
110
126
  self.register_tool(GlobTool(self.connection))
111
127
  self.register_tool(WebTool(self.connection))
@@ -131,15 +147,21 @@ class AgentToolkit:
131
147
  # In code mode: full write access
132
148
  from .tools.coding import (
133
149
  WriteToFileTool,
134
- ApplyDiffTool,
135
150
  DeleteFileTool,
136
151
  ExecuteCommandTool,
137
152
  )
138
153
  self.register_tool(WriteToFileTool(self._repo_root, self.connection))
139
- self.register_tool(ApplyDiffTool(self._repo_root, self.connection))
140
154
  self.register_tool(DeleteFileTool(self._repo_root, self.connection))
141
155
  self.register_tool(ExecuteCommandTool(self._repo_root, self.connection))
142
156
 
157
+ # Toggle between apply_diff (default) and edit_file based on env var
158
+ if os.getenv("EMDASH_ENABLE_APPLY_DIFF", "true").lower() in ("0", "false", "no"):
159
+ from .tools.coding import EditFileTool
160
+ self.register_tool(EditFileTool(self._repo_root, self.connection))
161
+ else:
162
+ from .tools.coding import ApplyDiffTool
163
+ self.register_tool(ApplyDiffTool(self._repo_root, self.connection))
164
+
143
165
  # Register sub-agent tools for spawning lightweight agents
144
166
  self._register_subagent_tools()
145
167
 
@@ -169,16 +191,22 @@ class AgentToolkit:
169
191
  log.debug(f"Registered {len(self._tools)} agent tools")
170
192
 
171
193
  def _register_subagent_tools(self) -> None:
172
- """Register sub-agent tools for spawning lightweight agents.
173
-
174
- These tools allow spawning specialized sub-agents as subprocesses
175
- for focused tasks like exploration and planning.
194
+ """Register sub-agent and background task management tools.
195
+
196
+ These tools allow:
197
+ - Spawning specialized sub-agents as subprocesses
198
+ - Running shell commands in the background
199
+ - Getting output from background tasks
200
+ - Killing background tasks
201
+ - Listing all background tasks
176
202
  """
177
203
  from .tools.task import TaskTool
178
- from .tools.task_output import TaskOutputTool
204
+ from .tools.task_output import TaskOutputTool, KillTaskTool, ListTasksTool
179
205
 
180
206
  self.register_tool(TaskTool(repo_root=self._repo_root, connection=self.connection))
181
207
  self.register_tool(TaskOutputTool(repo_root=self._repo_root, connection=self.connection))
208
+ self.register_tool(KillTaskTool(repo_root=self._repo_root, connection=self.connection))
209
+ self.register_tool(ListTasksTool(repo_root=self._repo_root, connection=self.connection))
182
210
 
183
211
  def _register_mode_tools(self) -> None:
184
212
  """Register mode switching tools.
@@ -347,10 +375,58 @@ class AgentToolkit:
347
375
  if tools:
348
376
  log.info(f"Registered {len(tools)} dynamic MCP tools from config")
349
377
 
378
+ # Register LSP tools if USE_LSP is enabled (default: true)
379
+ self._register_lsp_tools()
380
+
350
381
  except Exception as e:
351
382
  log.warning(f"Failed to initialize MCP manager: {e}")
352
383
  self._mcp_manager = None
353
384
 
385
+ def _register_lsp_tools(self) -> None:
386
+ """Register LSP-based code navigation tools.
387
+
388
+ These tools use cclsp MCP server to provide IDE-level code intelligence.
389
+ Enabled by default with USE_LSP=true. Set USE_LSP=false to disable.
390
+ """
391
+ from .tools.lsp import (
392
+ is_lsp_enabled,
393
+ LSPFindDefinitionTool,
394
+ LSPFindReferencesTool,
395
+ LSPRenameSymbolTool,
396
+ LSPGetDiagnosticsTool,
397
+ )
398
+
399
+ if not is_lsp_enabled():
400
+ log.info("LSP tools disabled (USE_LSP=false)")
401
+ return
402
+
403
+ if self._mcp_manager is None:
404
+ log.warning("Cannot register LSP tools: MCP manager not initialized")
405
+ return
406
+
407
+ # Check if cclsp is available
408
+ config = self._mcp_manager.load_config()
409
+ cclsp_config = config.get_server("cclsp")
410
+ if not cclsp_config or not cclsp_config.enabled:
411
+ log.info("LSP tools not registered: cclsp MCP server not enabled")
412
+ return
413
+
414
+ # Register LSP tools with better descriptions for the agent
415
+ lsp_tools = [
416
+ LSPFindDefinitionTool(self._mcp_manager, self.connection),
417
+ LSPFindReferencesTool(self._mcp_manager, self.connection),
418
+ LSPRenameSymbolTool(self._mcp_manager, self.connection),
419
+ LSPGetDiagnosticsTool(self._mcp_manager, self.connection),
420
+ ]
421
+
422
+ for tool in lsp_tools:
423
+ # LSP tools take priority - overwrite if exists
424
+ if tool.name in self._tools:
425
+ log.debug(f"LSP tool '{tool.name}' overwriting existing tool")
426
+ self.register_tool(tool)
427
+
428
+ log.info(f"Registered {len(lsp_tools)} LSP tools (USE_LSP=true)")
429
+
354
430
  def get_mcp_manager(self):
355
431
  """Get the MCP manager instance.
356
432