alita-sdk 0.3.465__py3-none-any.whl → 0.3.497__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.

Potentially problematic release.


This version of alita-sdk might be problematic. Click here for more details.

Files changed (103) hide show
  1. alita_sdk/cli/agent/__init__.py +5 -0
  2. alita_sdk/cli/agent/default.py +83 -1
  3. alita_sdk/cli/agent_loader.py +22 -4
  4. alita_sdk/cli/agent_ui.py +13 -3
  5. alita_sdk/cli/agents.py +1876 -186
  6. alita_sdk/cli/callbacks.py +96 -25
  7. alita_sdk/cli/cli.py +10 -1
  8. alita_sdk/cli/config.py +151 -9
  9. alita_sdk/cli/context/__init__.py +30 -0
  10. alita_sdk/cli/context/cleanup.py +198 -0
  11. alita_sdk/cli/context/manager.py +731 -0
  12. alita_sdk/cli/context/message.py +285 -0
  13. alita_sdk/cli/context/strategies.py +289 -0
  14. alita_sdk/cli/context/token_estimation.py +127 -0
  15. alita_sdk/cli/input_handler.py +167 -4
  16. alita_sdk/cli/inventory.py +1256 -0
  17. alita_sdk/cli/toolkit.py +14 -17
  18. alita_sdk/cli/toolkit_loader.py +35 -5
  19. alita_sdk/cli/tools/__init__.py +8 -1
  20. alita_sdk/cli/tools/filesystem.py +910 -64
  21. alita_sdk/cli/tools/planning.py +143 -157
  22. alita_sdk/cli/tools/terminal.py +154 -20
  23. alita_sdk/community/__init__.py +64 -8
  24. alita_sdk/community/inventory/__init__.py +224 -0
  25. alita_sdk/community/inventory/config.py +257 -0
  26. alita_sdk/community/inventory/enrichment.py +2137 -0
  27. alita_sdk/community/inventory/extractors.py +1469 -0
  28. alita_sdk/community/inventory/ingestion.py +3172 -0
  29. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  30. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  31. alita_sdk/community/inventory/parsers/base.py +295 -0
  32. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  33. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  34. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  35. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  36. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  37. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  38. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  39. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  40. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  41. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  42. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  43. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  44. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  45. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  46. alita_sdk/community/inventory/patterns/loader.py +348 -0
  47. alita_sdk/community/inventory/patterns/registry.py +198 -0
  48. alita_sdk/community/inventory/presets.py +535 -0
  49. alita_sdk/community/inventory/retrieval.py +1403 -0
  50. alita_sdk/community/inventory/toolkit.py +169 -0
  51. alita_sdk/community/inventory/visualize.py +1370 -0
  52. alita_sdk/configurations/bitbucket.py +0 -3
  53. alita_sdk/runtime/clients/client.py +108 -31
  54. alita_sdk/runtime/langchain/assistant.py +4 -2
  55. alita_sdk/runtime/langchain/constants.py +3 -1
  56. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +103 -60
  57. alita_sdk/runtime/langchain/document_loaders/constants.py +10 -6
  58. alita_sdk/runtime/langchain/langraph_agent.py +123 -31
  59. alita_sdk/runtime/llms/preloaded.py +2 -6
  60. alita_sdk/runtime/toolkits/__init__.py +2 -0
  61. alita_sdk/runtime/toolkits/application.py +1 -1
  62. alita_sdk/runtime/toolkits/mcp.py +107 -91
  63. alita_sdk/runtime/toolkits/planning.py +173 -0
  64. alita_sdk/runtime/toolkits/tools.py +59 -7
  65. alita_sdk/runtime/tools/artifact.py +46 -17
  66. alita_sdk/runtime/tools/function.py +2 -1
  67. alita_sdk/runtime/tools/llm.py +320 -32
  68. alita_sdk/runtime/tools/mcp_remote_tool.py +23 -7
  69. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  70. alita_sdk/runtime/tools/planning/models.py +246 -0
  71. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  72. alita_sdk/runtime/tools/vectorstore_base.py +44 -9
  73. alita_sdk/runtime/utils/AlitaCallback.py +106 -20
  74. alita_sdk/runtime/utils/mcp_client.py +465 -0
  75. alita_sdk/runtime/utils/mcp_oauth.py +80 -0
  76. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  77. alita_sdk/runtime/utils/streamlit.py +6 -10
  78. alita_sdk/runtime/utils/toolkit_utils.py +14 -5
  79. alita_sdk/tools/__init__.py +54 -27
  80. alita_sdk/tools/ado/repos/repos_wrapper.py +1 -2
  81. alita_sdk/tools/base_indexer_toolkit.py +99 -20
  82. alita_sdk/tools/bitbucket/__init__.py +2 -2
  83. alita_sdk/tools/chunkers/__init__.py +3 -1
  84. alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
  85. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  86. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  87. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  88. alita_sdk/tools/code_indexer_toolkit.py +55 -22
  89. alita_sdk/tools/confluence/api_wrapper.py +63 -14
  90. alita_sdk/tools/elitea_base.py +86 -21
  91. alita_sdk/tools/jira/__init__.py +1 -1
  92. alita_sdk/tools/jira/api_wrapper.py +91 -40
  93. alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
  94. alita_sdk/tools/qtest/__init__.py +1 -1
  95. alita_sdk/tools/sharepoint/api_wrapper.py +2 -2
  96. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +17 -13
  97. alita_sdk/tools/zephyr_essential/api_wrapper.py +12 -13
  98. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/METADATA +2 -1
  99. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/RECORD +103 -61
  100. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/WHEEL +0 -0
  101. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/entry_points.txt +0 -0
  102. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/licenses/LICENSE +0 -0
  103. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,10 @@ Sessions are persisted to $ALITA_DIR/sessions/<session_id>/
6
6
  - plan.json: Execution plan with steps
7
7
  - memory.db: SQLite database for conversation memory
8
8
  - session.json: Session metadata (agent, model, etc.)
9
+
10
+ This module re-exports the runtime PlanningToolkit for unified usage across
11
+ CLI, indexer_worker, and SDK agents. The runtime toolkit supports both
12
+ PostgreSQL (production) and filesystem (local) storage backends.
9
13
  """
10
14
 
11
15
  import os
@@ -21,9 +25,13 @@ import logging
21
25
  logger = logging.getLogger(__name__)
22
26
 
23
27
 
28
+ # ============================================================================
29
+ # Session Management Functions
30
+ # ============================================================================
31
+
24
32
  def get_sessions_dir() -> Path:
25
- """Get the sessions directory path."""
26
- alita_dir = os.environ.get('ALITA_DIR', os.path.expanduser('~/.alita'))
33
+ """Get the sessions directory path (relative to $ALITA_DIR or .alita)."""
34
+ alita_dir = os.environ.get('ALITA_DIR', '.alita')
27
35
  return Path(alita_dir) / 'sessions'
28
36
 
29
37
 
@@ -102,6 +110,88 @@ def load_session_metadata(session_id: str) -> Optional[Dict[str, Any]]:
102
110
  return None
103
111
 
104
112
 
113
+ def update_session_metadata(session_id: str, updates: Dict[str, Any]) -> None:
114
+ """
115
+ Update session metadata by merging new fields into existing metadata.
116
+
117
+ This preserves existing fields while updating/adding new ones.
118
+
119
+ Args:
120
+ session_id: The session ID
121
+ updates: Dictionary with fields to update/add
122
+ """
123
+ existing = load_session_metadata(session_id) or {}
124
+ existing.update(updates)
125
+ save_session_metadata(session_id, existing)
126
+ logger.debug(f"Updated session metadata with: {list(updates.keys())}")
127
+
128
+
129
+ def get_alita_dir() -> Path:
130
+ """Get the ALITA_DIR path (relative to $ALITA_DIR or .alita)."""
131
+ return Path(os.environ.get('ALITA_DIR', '.alita'))
132
+
133
+
134
+ def to_portable_path(path: str) -> str:
135
+ """
136
+ Convert an absolute path to a portable path for session storage.
137
+
138
+ If the path is under $ALITA_DIR, store as relative path (e.g., 'agents/my-agent.yaml').
139
+ Otherwise, store the absolute path.
140
+
141
+ Args:
142
+ path: Absolute file path
143
+
144
+ Returns:
145
+ Portable path string (relative to ALITA_DIR if applicable, else absolute)
146
+ """
147
+ if not path:
148
+ return path
149
+
150
+ try:
151
+ path_obj = Path(path).resolve()
152
+ alita_dir = get_alita_dir().resolve()
153
+
154
+ # Check if path is under ALITA_DIR
155
+ if str(path_obj).startswith(str(alita_dir)):
156
+ relative = path_obj.relative_to(alita_dir)
157
+ return str(relative)
158
+ except (ValueError, OSError):
159
+ pass
160
+
161
+ return str(path)
162
+
163
+
164
+ def from_portable_path(portable_path: str) -> str:
165
+ """
166
+ Convert a portable path back to an absolute path.
167
+
168
+ If the path is relative, resolve it against $ALITA_DIR.
169
+ Otherwise, return as-is.
170
+
171
+ Args:
172
+ portable_path: Portable path string from session storage
173
+
174
+ Returns:
175
+ Absolute file path
176
+ """
177
+ if not portable_path:
178
+ return portable_path
179
+
180
+ path_obj = Path(portable_path)
181
+
182
+ # If already absolute, return as-is
183
+ if path_obj.is_absolute():
184
+ return str(path_obj)
185
+
186
+ # Resolve relative path against ALITA_DIR
187
+ alita_dir = get_alita_dir()
188
+ return str(alita_dir / portable_path)
189
+
190
+
191
+ # ============================================================================
192
+ # PlanState - For CLI UI compatibility and session listing
193
+ # ============================================================================
194
+
105
195
  class PlanStep(BaseModel):
106
196
  """A single step in a plan."""
107
197
  description: str = Field(description="Step description")
@@ -109,7 +199,12 @@ class PlanStep(BaseModel):
109
199
 
110
200
 
111
201
  class PlanState(BaseModel):
112
- """Current plan state."""
202
+ """
203
+ Current plan state for CLI display.
204
+
205
+ This is used for CLI UI rendering and backwards compatibility.
206
+ The actual plan storage is handled by the runtime PlanningWrapper.
207
+ """
113
208
  title: str = Field(default="", description="Plan title")
114
209
  steps: List[PlanStep] = Field(default_factory=list, description="List of steps")
115
210
  session_id: str = Field(default="", description="Session ID for persistence")
@@ -148,23 +243,6 @@ class PlanState(BaseModel):
148
243
  session_id=data.get("session_id", "")
149
244
  )
150
245
 
151
- def save(self) -> Optional[Path]:
152
- """Save plan state to session file."""
153
- if not self.session_id:
154
- return None
155
-
156
- try:
157
- session_dir = get_sessions_dir() / self.session_id
158
- session_dir.mkdir(parents=True, exist_ok=True)
159
-
160
- plan_file = session_dir / "plan.json"
161
- plan_file.write_text(json.dumps(self.to_dict(), indent=2))
162
- logger.debug(f"Saved plan to {plan_file}")
163
- return plan_file
164
- except Exception as e:
165
- logger.warning(f"Failed to save plan: {e}")
166
- return None
167
-
168
246
  @classmethod
169
247
  def load(cls, session_id: str) -> Optional["PlanState"]:
170
248
  """Load plan state from session file."""
@@ -248,121 +326,9 @@ def list_sessions() -> List[Dict[str, Any]]:
248
326
  return sessions
249
327
 
250
328
 
251
- class UpdatePlanInput(BaseModel):
252
- """Input for updating the plan."""
253
- title: str = Field(description="Title for the plan (e.g., 'Test Investigation Plan')")
254
- steps: List[str] = Field(description="List of step descriptions in order")
255
-
256
-
257
- class CompleteStepInput(BaseModel):
258
- """Input for marking a step as complete."""
259
- step_number: int = Field(description="Step number to mark as complete (1-indexed)")
260
-
261
-
262
- class UpdatePlanTool(BaseTool):
263
- """Create or update the execution plan."""
264
-
265
- name: str = "update_plan"
266
- description: str = """Create or replace the current execution plan.
267
-
268
- Use this when:
269
- - Starting a multi-step task that needs tracking
270
- - The sequence of activities matters
271
- - Breaking down a complex task into phases
272
-
273
- The plan will be displayed to the user and you can mark steps complete as you progress.
274
- Plans are automatically saved and can be resumed in future sessions.
275
-
276
- Example:
277
- update_plan(
278
- title="API Test Investigation",
279
- steps=[
280
- "Reproduce the failing test locally",
281
- "Capture error logs and stack trace",
282
- "Identify root cause",
283
- "Apply fix to test or code",
284
- "Re-run test suite to verify"
285
- ]
286
- )"""
287
- args_schema: type[BaseModel] = UpdatePlanInput
288
-
289
- # Reference to shared plan state (set by executor)
290
- plan_state: Optional[PlanState] = None
291
- _plan_callback: Optional[Callable] = None
292
-
293
- def __init__(self, plan_state: Optional[PlanState] = None, plan_callback: Optional[Callable] = None, **kwargs):
294
- super().__init__(**kwargs)
295
- self.plan_state = plan_state or PlanState()
296
- self._plan_callback = plan_callback
297
-
298
- def _run(self, title: str, steps: List[str]) -> str:
299
- """Update the plan with new steps."""
300
- self.plan_state.title = title
301
- self.plan_state.steps = [PlanStep(description=s) for s in steps]
302
-
303
- # Auto-save to session
304
- saved_path = self.plan_state.save()
305
-
306
- # Notify callback if set (for UI rendering)
307
- if self._plan_callback:
308
- self._plan_callback(self.plan_state)
309
-
310
- result = f"Plan updated:\n\n{self.plan_state.render()}"
311
- if saved_path:
312
- result += f"\n\n[dim]Session: {self.plan_state.session_id}[/dim]"
313
- return result
314
-
315
-
316
- class CompleteStepTool(BaseTool):
317
- """Mark a plan step as complete."""
318
-
319
- name: str = "complete_step"
320
- description: str = """Mark a step in the current plan as completed.
321
-
322
- Use this after finishing a step to update the plan progress.
323
- Step numbers are 1-indexed (first step is 1, not 0).
324
- Progress is automatically saved.
325
-
326
- Example:
327
- complete_step(step_number=1) # Mark first step as done"""
328
- args_schema: type[BaseModel] = CompleteStepInput
329
-
330
- # Reference to shared plan state (set by executor)
331
- plan_state: Optional[PlanState] = None
332
- _plan_callback: Optional[Callable] = None
333
-
334
- def __init__(self, plan_state: Optional[PlanState] = None, plan_callback: Optional[Callable] = None, **kwargs):
335
- super().__init__(**kwargs)
336
- self.plan_state = plan_state or PlanState()
337
- self._plan_callback = plan_callback
338
-
339
- def _run(self, step_number: int) -> str:
340
- """Mark a step as complete."""
341
- if not self.plan_state.steps:
342
- return "No plan exists. Use update_plan first to create a plan."
343
-
344
- if step_number < 1 or step_number > len(self.plan_state.steps):
345
- return f"Invalid step number. Plan has {len(self.plan_state.steps)} steps (1-{len(self.plan_state.steps)})."
346
-
347
- step = self.plan_state.steps[step_number - 1]
348
- if step.completed:
349
- return f"Step {step_number} was already completed."
350
-
351
- step.completed = True
352
-
353
- # Auto-save to session
354
- self.plan_state.save()
355
-
356
- # Notify callback if set (for UI rendering)
357
- if self._plan_callback:
358
- self._plan_callback(self.plan_state)
359
-
360
- # Count progress
361
- completed = sum(1 for s in self.plan_state.steps if s.completed)
362
- total = len(self.plan_state.steps)
363
-
364
- return f"✓ Step {step_number} completed ({completed}/{total} done)\n\n{self.plan_state.render()}"
365
-
329
+ # ============================================================================
330
+ # Planning Tools - Using Runtime PlanningToolkit
331
+ # ============================================================================
366
332
 
367
333
  def get_planning_tools(
368
334
  plan_state: Optional[PlanState] = None,
@@ -370,34 +336,54 @@ def get_planning_tools(
370
336
  session_id: Optional[str] = None
371
337
  ) -> tuple[List[BaseTool], PlanState]:
372
338
  """
373
- Get planning tools with shared state.
339
+ Get planning tools using the runtime PlanningToolkit.
340
+
341
+ Uses the runtime PlanningToolkit which supports both PostgreSQL
342
+ and filesystem storage. For CLI, it uses filesystem storage with
343
+ session_id as the thread identifier.
374
344
 
375
345
  Args:
376
- plan_state: Optional existing plan state to use
377
- plan_callback: Optional callback function called when plan changes
378
- session_id: Optional session ID for persistence. If provided and plan exists,
379
- will load from disk. If None, generates a new session ID.
346
+ plan_state: Optional existing plan state (for backwards compatibility)
347
+ plan_callback: Optional callback function called when plan changes (for CLI UI)
348
+ session_id: Optional session ID for persistence. If None, generates a new one.
380
349
 
381
350
  Returns:
382
351
  Tuple of (list of tools, plan state object)
383
352
  """
384
- # Try to load existing session or create new one
385
- if session_id:
386
- loaded = PlanState.load(session_id)
387
- if loaded:
388
- state = loaded
389
- logger.info(f"Resumed session {session_id} with plan: {state.title}")
390
- else:
391
- state = plan_state or PlanState()
392
- state.session_id = session_id
393
- else:
394
- state = plan_state or PlanState()
395
- if not state.session_id:
396
- state.session_id = generate_session_id()
353
+ from alita_sdk.runtime.toolkits.planning import PlanningToolkit
354
+ from alita_sdk.runtime.tools.planning.wrapper import PlanState as RuntimePlanState
355
+
356
+ # Generate session_id if not provided
357
+ if not session_id:
358
+ session_id = generate_session_id()
359
+
360
+ # Create adapter callback that converts between PlanState types
361
+ def adapter_callback(runtime_plan: RuntimePlanState):
362
+ if plan_callback:
363
+ # Convert runtime PlanState to CLI PlanState for UI
364
+ cli_plan = PlanState(
365
+ title=runtime_plan.title,
366
+ steps=[PlanStep(description=s.description, completed=s.completed) for s in runtime_plan.steps],
367
+ session_id=session_id
368
+ )
369
+ plan_callback(cli_plan)
370
+
371
+ # Create toolkit with filesystem storage (no pgvector_configuration)
372
+ # Use session_id as conversation_id so tools don't need it passed explicitly
373
+ toolkit = PlanningToolkit.get_toolkit(
374
+ toolkit_name=None, # No prefix - tools are called directly
375
+ selected_tools=['update_plan', 'complete_step', 'get_plan_status', 'delete_plan'],
376
+ pgvector_configuration=None, # Uses filesystem storage
377
+ storage_dir=str(get_sessions_dir() / session_id), # Use session-specific directory
378
+ plan_callback=adapter_callback if plan_callback else None,
379
+ conversation_id=session_id # Use session_id as conversation_id
380
+ )
381
+
382
+ tools = toolkit.get_tools()
397
383
 
398
- tools = [
399
- UpdatePlanTool(plan_state=state, plan_callback=plan_callback),
400
- CompleteStepTool(plan_state=state, plan_callback=plan_callback),
401
- ]
384
+ # Create local state for return (for backward compatibility)
385
+ loaded = PlanState.load(session_id)
386
+ state = loaded if loaded else (plan_state or PlanState())
387
+ state.session_id = session_id
402
388
 
403
389
  return tools, state
@@ -66,6 +66,10 @@ class TerminalRunCommandInput(BaseModel):
66
66
  """Input for running a terminal command."""
67
67
  command: str = Field(description="Shell command to execute")
68
68
  timeout: int = Field(default=300, description="Timeout in seconds (default: 300)")
69
+ directory: Optional[str] = Field(
70
+ default=None,
71
+ description="Working directory to execute the command in. Must be from the allowed directories list. If not specified, uses the default workspace directory."
72
+ )
69
73
 
70
74
 
71
75
  class TerminalRunCommandTool(BaseTool):
@@ -75,7 +79,7 @@ class TerminalRunCommandTool(BaseTool):
75
79
  description: str = """Execute a shell command in the workspace directory.
76
80
 
77
81
  Use this to run tests, build commands, git operations, package managers, etc.
78
- Commands are executed in the mounted workspace directory.
82
+ Commands are executed in the mounted workspace directory or a specified allowed directory.
79
83
 
80
84
  Examples:
81
85
  - Run tests: `npm test`, `pytest`, `go test ./...`
@@ -83,17 +87,28 @@ Examples:
83
87
  - Git: `git status`, `git diff`, `git log --oneline -10`
84
88
  - Package managers: `npm install`, `pip install -r requirements.txt`
85
89
 
86
- The command runs with the workspace as the current working directory.
87
- Returns stdout, stderr, and exit code."""
90
+ The command runs with the workspace (or specified directory) as the current working directory.
91
+ Returns stdout, stderr, and exit code.
92
+
93
+ Use the 'directory' parameter to run commands in a specific allowed directory when working with multi-folder workspaces."""
88
94
  args_schema: type[BaseModel] = TerminalRunCommandInput
89
95
 
90
96
  work_dir: str = ""
97
+ allowed_directories: List[str] = []
91
98
  blocked_patterns: List[str] = []
92
99
 
93
- def __init__(self, work_dir: str, blocked_patterns: Optional[List[str]] = None, **kwargs):
100
+ def __init__(self, work_dir: str, blocked_patterns: Optional[List[str]] = None,
101
+ allowed_directories: Optional[List[str]] = None, **kwargs):
94
102
  super().__init__(**kwargs)
95
103
  self.work_dir = str(Path(work_dir).resolve())
96
104
  self.blocked_patterns = blocked_patterns or DEFAULT_BLOCKED_PATTERNS
105
+ # Build allowed directories list: always include work_dir, plus any additional allowed dirs
106
+ self.allowed_directories = [self.work_dir]
107
+ if allowed_directories:
108
+ for d in allowed_directories:
109
+ resolved = str(Path(d).resolve())
110
+ if resolved not in self.allowed_directories:
111
+ self.allowed_directories.append(resolved)
97
112
 
98
113
  def _is_command_blocked(self, command: str) -> tuple[bool, str]:
99
114
  """Check if command matches any blocked patterns."""
@@ -103,52 +118,110 @@ Returns stdout, stderr, and exit code."""
103
118
  return True, pattern
104
119
  return False, ""
105
120
 
106
- def _validate_paths_in_command(self, command: str) -> tuple[bool, str]:
121
+ def _validate_directory(self, directory: Optional[str]) -> tuple[bool, str, str]:
122
+ """
123
+ Validate that the requested directory is in the allowed list.
124
+
125
+ Args:
126
+ directory: Requested directory path or None
127
+
128
+ Returns:
129
+ Tuple of (is_valid, error_message, resolved_directory)
107
130
  """
108
- Validate that any paths referenced in the command don't escape work_dir.
131
+ if directory is None:
132
+ return True, "", self.work_dir
133
+
134
+ # Resolve the requested directory
135
+ try:
136
+ resolved = str(Path(directory).resolve())
137
+ except Exception as e:
138
+ return False, f"Invalid directory path: {e}", ""
139
+
140
+ # Check if directory exists
141
+ if not Path(resolved).is_dir():
142
+ return False, f"Directory does not exist: {directory}", ""
143
+
144
+ # Check if it's in the allowed list or is a subdirectory of an allowed directory
145
+ for allowed in self.allowed_directories:
146
+ if resolved == allowed or resolved.startswith(allowed + os.sep):
147
+ return True, "", resolved
148
+
149
+ allowed_list = "\n - ".join(self.allowed_directories)
150
+ return False, f"Directory not in allowed list: {directory}\n\nAllowed directories:\n - {allowed_list}", ""
151
+
152
+ def _validate_paths_in_command(self, command: str, target_dir: str) -> tuple[bool, str]:
153
+ """
154
+ Validate that any paths referenced in the command don't escape allowed directories.
109
155
  This is a best-effort check for obvious path traversal.
156
+
157
+ Args:
158
+ command: The command to validate
159
+ target_dir: The target directory where command will be executed
110
160
  """
111
161
  # Check for obvious path traversal patterns
112
162
  if "../../../" in command or "/.." in command:
113
163
  return False, "Path traversal detected"
114
164
 
115
- # Check for absolute paths outside work_dir
116
- parts = shlex.split(command)
165
+ # Check for absolute paths outside allowed directories
166
+ try:
167
+ parts = shlex.split(command)
168
+ except ValueError:
169
+ # If we can't parse the command, skip path validation
170
+ parts = []
171
+
117
172
  for part in parts:
118
- if part.startswith("/") and not part.startswith(self.work_dir):
173
+ if part.startswith("/"):
119
174
  # Allow common system paths that are safe to reference
120
175
  safe_prefixes = ["/dev/null", "/tmp", "/usr/bin", "/usr/local/bin", "/bin"]
121
- if not any(part.startswith(p) for p in safe_prefixes):
122
- return False, f"Absolute path outside workspace: {part}"
176
+ if any(part.startswith(p) for p in safe_prefixes):
177
+ continue
178
+ # Check if it's within any allowed directory
179
+ is_allowed = False
180
+ for allowed in self.allowed_directories:
181
+ if part.startswith(allowed) or part == allowed:
182
+ is_allowed = True
183
+ break
184
+ if not is_allowed:
185
+ return False, f"Absolute path outside allowed directories: {part}"
123
186
 
124
187
  return True, ""
125
188
 
126
- def _run(self, command: str, timeout: int = 300) -> str:
189
+ def _run(self, command: str, timeout: int = 300, directory: Optional[str] = None) -> str:
127
190
  """Execute the command and return results."""
191
+ # Validate the requested directory
192
+ dir_valid, dir_error, target_dir = self._validate_directory(directory)
193
+ if not dir_valid:
194
+ return f"❌ Directory validation failed: {dir_error}"
195
+
128
196
  # Check if command is blocked
129
197
  is_blocked, pattern = self._is_command_blocked(command)
130
198
  if is_blocked:
131
199
  return f"❌ Command blocked for security reasons.\nMatched pattern: {pattern}\n\nThis command pattern is not allowed. Please use a safer alternative."
132
200
 
133
201
  # Validate paths in command
134
- path_valid, path_error = self._validate_paths_in_command(command)
202
+ path_valid, path_error = self._validate_paths_in_command(command, target_dir)
135
203
  if not path_valid:
136
- return f" Command rejected: {path_error}\n\nCommands must operate within the workspace directory: {self.work_dir}"
204
+ allowed_list = ", ".join(self.allowed_directories)
205
+ return f"❌ Command rejected: {path_error}\n\nCommands must operate within allowed directories: {allowed_list}"
137
206
 
138
207
  try:
139
- # Execute command in work_dir
208
+ # Execute command in target_dir
140
209
  result = subprocess.run(
141
210
  command,
142
211
  shell=True,
143
- cwd=self.work_dir,
212
+ cwd=target_dir,
144
213
  capture_output=True,
145
214
  text=True,
146
215
  timeout=timeout,
147
- env={**os.environ, "PWD": self.work_dir}
216
+ env={**os.environ, "PWD": target_dir}
148
217
  )
149
218
 
150
219
  output_parts = []
151
220
 
221
+ # Show which directory the command was executed in if not default
222
+ if target_dir != self.work_dir:
223
+ output_parts.append(f"[Executed in: {target_dir}]")
224
+
152
225
  if result.stdout:
153
226
  output_parts.append(f"stdout:\n{result.stdout}")
154
227
 
@@ -157,12 +230,58 @@ Returns stdout, stderr, and exit code."""
157
230
 
158
231
  output_parts.append(f"exit_code: {result.returncode}")
159
232
 
233
+ # Add hint when search-like commands return empty results
234
+ if result.returncode == 0 and not result.stdout.strip():
235
+ if self._is_search_command(command):
236
+ hint = self._generate_empty_search_hint(command, target_dir)
237
+ output_parts.append(hint)
238
+
160
239
  return "\n\n".join(output_parts)
161
240
 
162
241
  except subprocess.TimeoutExpired:
163
242
  return f"❌ Command timed out after {timeout} seconds.\n\nConsider:\n- Breaking into smaller operations\n- Using --timeout flag for longer operations\n- Running in background if appropriate"
164
243
  except Exception as e:
165
244
  return f"❌ Error executing command: {str(e)}"
245
+
246
+ def _is_search_command(self, command: str) -> bool:
247
+ """Check if the command is a search/find operation."""
248
+ search_patterns = [
249
+ r'\bfind\b',
250
+ r'\bgrep\b',
251
+ r'\brg\b', # ripgrep
252
+ r'\bag\b', # silver searcher
253
+ r'\back\b',
254
+ r'\bfzf\b',
255
+ r'\blocate\b',
256
+ r'\bfd\b', # fd-find
257
+ r'\bxargs\s+grep',
258
+ ]
259
+ command_lower = command.lower()
260
+ return any(re.search(pattern, command_lower) for pattern in search_patterns)
261
+
262
+ def _generate_empty_search_hint(self, command: str, target_dir: str) -> str:
263
+ """Generate a helpful hint when a search command returns no results."""
264
+ hints = ["💡 **No results found.** Consider:"]
265
+
266
+ # Check if searching in the right directory
267
+ if len(self.allowed_directories) > 1:
268
+ other_dirs = [d for d in self.allowed_directories if d != target_dir]
269
+ hints.append(f" - **Wrong directory?** You searched in `{target_dir}`")
270
+ hints.append(f" Other allowed directories: {', '.join(f'`{d}`' for d in other_dirs)}")
271
+ else:
272
+ hints.append(f" - **Verify directory:** Currently searching in `{target_dir}`")
273
+
274
+ # Suggest adjusting search criteria
275
+ hints.append(" - **Adjust search pattern:** Try broader terms, different casing, or partial matches")
276
+ hints.append(" - **Check file extensions:** Ensure you're searching the right file types")
277
+
278
+ # Specific suggestions based on command patterns
279
+ if 'grep' in command.lower():
280
+ hints.append(" - **Grep tips:** Use `-i` for case-insensitive, `-r` for recursive, or try regex patterns")
281
+ if 'find' in command.lower() and '-name' in command:
282
+ hints.append(" - **Find tips:** Use wildcards like `*.py` or `-iname` for case-insensitive matching")
283
+
284
+ return "\n".join(hints)
166
285
 
167
286
 
168
287
  def load_blocked_patterns(config_path: Optional[str] = None) -> List[str]:
@@ -195,14 +314,18 @@ def load_blocked_patterns(config_path: Optional[str] = None) -> List[str]:
195
314
 
196
315
  def get_terminal_tools(
197
316
  work_dir: str,
198
- blocked_patterns_path: Optional[str] = None
317
+ blocked_patterns_path: Optional[str] = None,
318
+ allowed_directories: Optional[List[str]] = None
199
319
  ) -> List[BaseTool]:
200
320
  """
201
321
  Get terminal execution tools for the given working directory.
202
322
 
203
323
  Args:
204
- work_dir: The workspace directory (must be absolute path)
324
+ work_dir: The default workspace directory (must be absolute path)
205
325
  blocked_patterns_path: Optional path to custom blocked_commands.txt
326
+ allowed_directories: Optional list of additional directories where commands can be executed.
327
+ The work_dir is always included in the allowed list.
328
+ This enables multi-folder workspace support.
206
329
 
207
330
  Returns:
208
331
  List of terminal tools
@@ -214,10 +337,21 @@ def get_terminal_tools(
214
337
 
215
338
  blocked_patterns = load_blocked_patterns(blocked_patterns_path)
216
339
 
340
+ # Validate and resolve allowed directories
341
+ validated_allowed_dirs = []
342
+ if allowed_directories:
343
+ for d in allowed_directories:
344
+ resolved = str(Path(d).resolve())
345
+ if Path(resolved).is_dir():
346
+ validated_allowed_dirs.append(resolved)
347
+ else:
348
+ logger.warning(f"Allowed directory does not exist, skipping: {d}")
349
+
217
350
  return [
218
351
  TerminalRunCommandTool(
219
352
  work_dir=work_dir,
220
- blocked_patterns=blocked_patterns
353
+ blocked_patterns=blocked_patterns,
354
+ allowed_directories=validated_allowed_dirs
221
355
  )
222
356
  ]
223
357