alita-sdk 0.3.465__py3-none-any.whl → 0.3.486__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.
- alita_sdk/cli/agent/__init__.py +5 -0
- alita_sdk/cli/agent/default.py +83 -1
- alita_sdk/cli/agent_loader.py +6 -9
- alita_sdk/cli/agent_ui.py +13 -3
- alita_sdk/cli/agents.py +1866 -185
- alita_sdk/cli/callbacks.py +96 -25
- alita_sdk/cli/cli.py +10 -1
- alita_sdk/cli/config.py +151 -9
- alita_sdk/cli/context/__init__.py +30 -0
- alita_sdk/cli/context/cleanup.py +198 -0
- alita_sdk/cli/context/manager.py +731 -0
- alita_sdk/cli/context/message.py +285 -0
- alita_sdk/cli/context/strategies.py +289 -0
- alita_sdk/cli/context/token_estimation.py +127 -0
- alita_sdk/cli/input_handler.py +167 -4
- alita_sdk/cli/inventory.py +1256 -0
- alita_sdk/cli/toolkit.py +14 -17
- alita_sdk/cli/toolkit_loader.py +35 -5
- alita_sdk/cli/tools/__init__.py +8 -1
- alita_sdk/cli/tools/filesystem.py +815 -55
- alita_sdk/cli/tools/planning.py +143 -157
- alita_sdk/cli/tools/terminal.py +154 -20
- alita_sdk/community/__init__.py +64 -8
- alita_sdk/community/inventory/__init__.py +224 -0
- alita_sdk/community/inventory/config.py +257 -0
- alita_sdk/community/inventory/enrichment.py +2137 -0
- alita_sdk/community/inventory/extractors.py +1469 -0
- alita_sdk/community/inventory/ingestion.py +3172 -0
- alita_sdk/community/inventory/knowledge_graph.py +1457 -0
- alita_sdk/community/inventory/parsers/__init__.py +218 -0
- alita_sdk/community/inventory/parsers/base.py +295 -0
- alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
- alita_sdk/community/inventory/parsers/go_parser.py +851 -0
- alita_sdk/community/inventory/parsers/html_parser.py +389 -0
- alita_sdk/community/inventory/parsers/java_parser.py +593 -0
- alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
- alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
- alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
- alita_sdk/community/inventory/parsers/python_parser.py +604 -0
- alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
- alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
- alita_sdk/community/inventory/parsers/text_parser.py +322 -0
- alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
- alita_sdk/community/inventory/patterns/__init__.py +61 -0
- alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
- alita_sdk/community/inventory/patterns/loader.py +348 -0
- alita_sdk/community/inventory/patterns/registry.py +198 -0
- alita_sdk/community/inventory/presets.py +535 -0
- alita_sdk/community/inventory/retrieval.py +1403 -0
- alita_sdk/community/inventory/toolkit.py +169 -0
- alita_sdk/community/inventory/visualize.py +1370 -0
- alita_sdk/configurations/bitbucket.py +0 -3
- alita_sdk/runtime/clients/client.py +84 -26
- alita_sdk/runtime/langchain/assistant.py +4 -2
- alita_sdk/runtime/langchain/langraph_agent.py +122 -31
- alita_sdk/runtime/llms/preloaded.py +2 -6
- alita_sdk/runtime/toolkits/__init__.py +2 -0
- alita_sdk/runtime/toolkits/application.py +1 -1
- alita_sdk/runtime/toolkits/mcp.py +46 -36
- alita_sdk/runtime/toolkits/planning.py +171 -0
- alita_sdk/runtime/toolkits/tools.py +39 -6
- alita_sdk/runtime/tools/llm.py +185 -8
- alita_sdk/runtime/tools/planning/__init__.py +36 -0
- alita_sdk/runtime/tools/planning/models.py +246 -0
- alita_sdk/runtime/tools/planning/wrapper.py +607 -0
- alita_sdk/runtime/tools/vectorstore_base.py +41 -6
- alita_sdk/runtime/utils/mcp_oauth.py +80 -0
- alita_sdk/runtime/utils/streamlit.py +6 -10
- alita_sdk/runtime/utils/toolkit_utils.py +19 -4
- alita_sdk/tools/__init__.py +54 -27
- alita_sdk/tools/ado/repos/repos_wrapper.py +1 -2
- alita_sdk/tools/base_indexer_toolkit.py +98 -19
- alita_sdk/tools/bitbucket/__init__.py +2 -2
- alita_sdk/tools/chunkers/__init__.py +3 -1
- alita_sdk/tools/chunkers/sematic/markdown_chunker.py +95 -6
- alita_sdk/tools/chunkers/universal_chunker.py +269 -0
- alita_sdk/tools/code_indexer_toolkit.py +55 -22
- alita_sdk/tools/elitea_base.py +86 -21
- alita_sdk/tools/jira/__init__.py +1 -1
- alita_sdk/tools/jira/api_wrapper.py +91 -40
- alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
- alita_sdk/tools/qtest/__init__.py +1 -1
- alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +8 -2
- alita_sdk/tools/zephyr_essential/api_wrapper.py +12 -13
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.486.dist-info}/METADATA +2 -1
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.486.dist-info}/RECORD +90 -50
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.486.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.486.dist-info}/entry_points.txt +0 -0
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.486.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.486.dist-info}/top_level.txt +0 -0
alita_sdk/cli/tools/planning.py
CHANGED
|
@@ -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',
|
|
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
|
-
"""
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
|
377
|
-
plan_callback: Optional callback function called when plan changes
|
|
378
|
-
session_id: Optional session ID for persistence. If
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
alita_sdk/cli/tools/terminal.py
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
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("/")
|
|
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
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
208
|
+
# Execute command in target_dir
|
|
140
209
|
result = subprocess.run(
|
|
141
210
|
command,
|
|
142
211
|
shell=True,
|
|
143
|
-
cwd=
|
|
212
|
+
cwd=target_dir,
|
|
144
213
|
capture_output=True,
|
|
145
214
|
text=True,
|
|
146
215
|
timeout=timeout,
|
|
147
|
-
env={**os.environ, "PWD":
|
|
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
|
|