alita-sdk 0.3.449__py3-none-any.whl → 0.3.465__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/__init__.py +10 -0
- alita_sdk/cli/__main__.py +17 -0
- alita_sdk/cli/agent/__init__.py +0 -0
- alita_sdk/cli/agent/default.py +176 -0
- alita_sdk/cli/agent_executor.py +155 -0
- alita_sdk/cli/agent_loader.py +197 -0
- alita_sdk/cli/agent_ui.py +218 -0
- alita_sdk/cli/agents.py +1911 -0
- alita_sdk/cli/callbacks.py +576 -0
- alita_sdk/cli/cli.py +159 -0
- alita_sdk/cli/config.py +164 -0
- alita_sdk/cli/formatting.py +182 -0
- alita_sdk/cli/input_handler.py +256 -0
- alita_sdk/cli/mcp_loader.py +315 -0
- alita_sdk/cli/toolkit.py +330 -0
- alita_sdk/cli/toolkit_loader.py +55 -0
- alita_sdk/cli/tools/__init__.py +36 -0
- alita_sdk/cli/tools/approval.py +224 -0
- alita_sdk/cli/tools/filesystem.py +905 -0
- alita_sdk/cli/tools/planning.py +403 -0
- alita_sdk/cli/tools/terminal.py +280 -0
- alita_sdk/runtime/clients/client.py +16 -1
- alita_sdk/runtime/langchain/constants.py +2 -1
- alita_sdk/runtime/langchain/langraph_agent.py +74 -20
- alita_sdk/runtime/langchain/utils.py +20 -4
- alita_sdk/runtime/toolkits/artifact.py +5 -6
- alita_sdk/runtime/toolkits/mcp.py +5 -2
- alita_sdk/runtime/toolkits/tools.py +1 -0
- alita_sdk/runtime/tools/function.py +19 -6
- alita_sdk/runtime/tools/llm.py +65 -7
- alita_sdk/runtime/tools/vectorstore_base.py +17 -2
- alita_sdk/runtime/utils/mcp_sse_client.py +64 -6
- alita_sdk/tools/ado/repos/__init__.py +1 -0
- alita_sdk/tools/ado/test_plan/__init__.py +1 -1
- alita_sdk/tools/ado/wiki/__init__.py +1 -5
- alita_sdk/tools/ado/work_item/__init__.py +1 -5
- alita_sdk/tools/base_indexer_toolkit.py +64 -8
- alita_sdk/tools/bitbucket/__init__.py +1 -0
- alita_sdk/tools/code/sonar/__init__.py +1 -1
- alita_sdk/tools/confluence/__init__.py +2 -2
- alita_sdk/tools/github/__init__.py +2 -2
- alita_sdk/tools/gitlab/__init__.py +2 -1
- alita_sdk/tools/gitlab_org/__init__.py +1 -2
- alita_sdk/tools/google_places/__init__.py +2 -1
- alita_sdk/tools/jira/__init__.py +1 -0
- alita_sdk/tools/memory/__init__.py +1 -1
- alita_sdk/tools/pandas/__init__.py +1 -1
- alita_sdk/tools/postman/__init__.py +2 -1
- alita_sdk/tools/pptx/__init__.py +2 -2
- alita_sdk/tools/qtest/__init__.py +3 -3
- alita_sdk/tools/qtest/api_wrapper.py +1235 -51
- alita_sdk/tools/rally/__init__.py +1 -2
- alita_sdk/tools/report_portal/__init__.py +1 -0
- alita_sdk/tools/salesforce/__init__.py +1 -0
- alita_sdk/tools/servicenow/__init__.py +2 -3
- alita_sdk/tools/sharepoint/__init__.py +1 -0
- alita_sdk/tools/sharepoint/api_wrapper.py +22 -2
- alita_sdk/tools/sharepoint/authorization_helper.py +17 -1
- alita_sdk/tools/slack/__init__.py +1 -0
- alita_sdk/tools/sql/__init__.py +2 -1
- alita_sdk/tools/testio/__init__.py +1 -0
- alita_sdk/tools/testrail/__init__.py +1 -3
- alita_sdk/tools/xray/__init__.py +2 -1
- alita_sdk/tools/zephyr/__init__.py +2 -1
- alita_sdk/tools/zephyr_enterprise/__init__.py +1 -0
- alita_sdk/tools/zephyr_essential/__init__.py +1 -0
- alita_sdk/tools/zephyr_scale/__init__.py +1 -0
- alita_sdk/tools/zephyr_squad/__init__.py +1 -0
- {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/METADATA +145 -2
- {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/RECORD +74 -52
- alita_sdk-0.3.465.dist-info/entry_points.txt +2 -0
- {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Planning tools for CLI agents.
|
|
3
|
+
|
|
4
|
+
Provides plan management for multi-step task execution with progress tracking.
|
|
5
|
+
Sessions are persisted to $ALITA_DIR/sessions/<session_id>/
|
|
6
|
+
- plan.json: Execution plan with steps
|
|
7
|
+
- memory.db: SQLite database for conversation memory
|
|
8
|
+
- session.json: Session metadata (agent, model, etc.)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
import sqlite3
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional, List, Dict, Any, Callable
|
|
17
|
+
from langchain_core.tools import BaseTool
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_sessions_dir() -> Path:
|
|
25
|
+
"""Get the sessions directory path."""
|
|
26
|
+
alita_dir = os.environ.get('ALITA_DIR', os.path.expanduser('~/.alita'))
|
|
27
|
+
return Path(alita_dir) / 'sessions'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_session_id() -> str:
|
|
31
|
+
"""Generate a new unique session ID."""
|
|
32
|
+
return uuid.uuid4().hex[:12]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_session_dir(session_id: str) -> Path:
|
|
36
|
+
"""Get the directory for a specific session."""
|
|
37
|
+
return get_sessions_dir() / session_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_session_memory_path(session_id: str) -> Path:
|
|
41
|
+
"""Get the path to the memory database for a session."""
|
|
42
|
+
session_dir = get_session_dir(session_id)
|
|
43
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
return session_dir / "memory.db"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_session_metadata_path(session_id: str) -> Path:
|
|
48
|
+
"""Get the path to the session metadata file."""
|
|
49
|
+
session_dir = get_session_dir(session_id)
|
|
50
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
return session_dir / "session.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_session_memory(session_id: str):
|
|
55
|
+
"""
|
|
56
|
+
Create a SQLite-based memory saver for the session.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
session_id: The session ID
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
SqliteSaver instance connected to the session's memory.db
|
|
63
|
+
"""
|
|
64
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
65
|
+
|
|
66
|
+
memory_path = get_session_memory_path(session_id)
|
|
67
|
+
conn = sqlite3.connect(str(memory_path), check_same_thread=False)
|
|
68
|
+
logger.debug(f"Created session memory at {memory_path}")
|
|
69
|
+
return SqliteSaver(conn)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_session_metadata(session_id: str, metadata: Dict[str, Any]) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Save session metadata (agent name, model, etc.).
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
session_id: The session ID
|
|
78
|
+
metadata: Dictionary with session metadata
|
|
79
|
+
"""
|
|
80
|
+
metadata_path = get_session_metadata_path(session_id)
|
|
81
|
+
metadata['session_id'] = session_id
|
|
82
|
+
metadata_path.write_text(json.dumps(metadata, indent=2))
|
|
83
|
+
logger.debug(f"Saved session metadata to {metadata_path}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_session_metadata(session_id: str) -> Optional[Dict[str, Any]]:
|
|
87
|
+
"""
|
|
88
|
+
Load session metadata.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
session_id: The session ID
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Session metadata dict or None if not found
|
|
95
|
+
"""
|
|
96
|
+
metadata_path = get_session_metadata_path(session_id)
|
|
97
|
+
if metadata_path.exists():
|
|
98
|
+
try:
|
|
99
|
+
return json.loads(metadata_path.read_text())
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.warning(f"Failed to load session metadata: {e}")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PlanStep(BaseModel):
|
|
106
|
+
"""A single step in a plan."""
|
|
107
|
+
description: str = Field(description="Step description")
|
|
108
|
+
completed: bool = Field(default=False, description="Whether step is completed")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PlanState(BaseModel):
|
|
112
|
+
"""Current plan state."""
|
|
113
|
+
title: str = Field(default="", description="Plan title")
|
|
114
|
+
steps: List[PlanStep] = Field(default_factory=list, description="List of steps")
|
|
115
|
+
session_id: str = Field(default="", description="Session ID for persistence")
|
|
116
|
+
|
|
117
|
+
def render(self) -> str:
|
|
118
|
+
"""Render plan as formatted string with checkboxes."""
|
|
119
|
+
if not self.steps:
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
lines = []
|
|
123
|
+
if self.title:
|
|
124
|
+
lines.append(f"📋 {self.title}")
|
|
125
|
+
|
|
126
|
+
for i, step in enumerate(self.steps, 1):
|
|
127
|
+
checkbox = "☑" if step.completed else "☐"
|
|
128
|
+
status = " (completed)" if step.completed else ""
|
|
129
|
+
lines.append(f" {checkbox} {i}. {step.description}{status}")
|
|
130
|
+
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
134
|
+
"""Convert to dictionary for serialization."""
|
|
135
|
+
return {
|
|
136
|
+
"title": self.title,
|
|
137
|
+
"steps": [{"description": s.description, "completed": s.completed} for s in self.steps],
|
|
138
|
+
"session_id": self.session_id
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_dict(cls, data: Dict[str, Any]) -> "PlanState":
|
|
143
|
+
"""Create from dictionary."""
|
|
144
|
+
steps = [PlanStep(**s) for s in data.get("steps", [])]
|
|
145
|
+
return cls(
|
|
146
|
+
title=data.get("title", ""),
|
|
147
|
+
steps=steps,
|
|
148
|
+
session_id=data.get("session_id", "")
|
|
149
|
+
)
|
|
150
|
+
|
|
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
|
+
@classmethod
|
|
169
|
+
def load(cls, session_id: str) -> Optional["PlanState"]:
|
|
170
|
+
"""Load plan state from session file."""
|
|
171
|
+
try:
|
|
172
|
+
plan_file = get_sessions_dir() / session_id / "plan.json"
|
|
173
|
+
if plan_file.exists():
|
|
174
|
+
data = json.loads(plan_file.read_text())
|
|
175
|
+
state = cls.from_dict(data)
|
|
176
|
+
state.session_id = session_id
|
|
177
|
+
logger.debug(f"Loaded plan from {plan_file}")
|
|
178
|
+
return state
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.warning(f"Failed to load plan: {e}")
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def list_sessions() -> List[Dict[str, Any]]:
|
|
185
|
+
"""List all sessions with their metadata and plans."""
|
|
186
|
+
sessions = []
|
|
187
|
+
sessions_dir = get_sessions_dir()
|
|
188
|
+
|
|
189
|
+
if not sessions_dir.exists():
|
|
190
|
+
return sessions
|
|
191
|
+
|
|
192
|
+
for session_dir in sessions_dir.iterdir():
|
|
193
|
+
if session_dir.is_dir():
|
|
194
|
+
session_info = {
|
|
195
|
+
"session_id": session_dir.name,
|
|
196
|
+
"title": None,
|
|
197
|
+
"steps_total": 0,
|
|
198
|
+
"steps_completed": 0,
|
|
199
|
+
"agent_name": None,
|
|
200
|
+
"model": None,
|
|
201
|
+
"modified": 0,
|
|
202
|
+
"has_memory": False,
|
|
203
|
+
"has_plan": False,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Load session metadata
|
|
207
|
+
metadata_file = session_dir / "session.json"
|
|
208
|
+
if metadata_file.exists():
|
|
209
|
+
try:
|
|
210
|
+
metadata = json.loads(metadata_file.read_text())
|
|
211
|
+
session_info["agent_name"] = metadata.get("agent_name")
|
|
212
|
+
session_info["model"] = metadata.get("model")
|
|
213
|
+
session_info["modified"] = metadata_file.stat().st_mtime
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# Check for memory database
|
|
218
|
+
memory_file = session_dir / "memory.db"
|
|
219
|
+
if memory_file.exists():
|
|
220
|
+
session_info["has_memory"] = True
|
|
221
|
+
# Use memory file mtime if newer
|
|
222
|
+
mem_mtime = memory_file.stat().st_mtime
|
|
223
|
+
if mem_mtime > session_info["modified"]:
|
|
224
|
+
session_info["modified"] = mem_mtime
|
|
225
|
+
|
|
226
|
+
# Load plan info
|
|
227
|
+
plan_file = session_dir / "plan.json"
|
|
228
|
+
if plan_file.exists():
|
|
229
|
+
try:
|
|
230
|
+
data = json.loads(plan_file.read_text())
|
|
231
|
+
session_info["has_plan"] = True
|
|
232
|
+
session_info["title"] = data.get("title", "(untitled)")
|
|
233
|
+
session_info["steps_total"] = len(data.get("steps", []))
|
|
234
|
+
session_info["steps_completed"] = sum(1 for s in data.get("steps", []) if s.get("completed"))
|
|
235
|
+
# Use plan file mtime if newer
|
|
236
|
+
plan_mtime = plan_file.stat().st_mtime
|
|
237
|
+
if plan_mtime > session_info["modified"]:
|
|
238
|
+
session_info["modified"] = plan_mtime
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
# Only include sessions that have some content
|
|
243
|
+
if session_info["has_memory"] or session_info["has_plan"]:
|
|
244
|
+
sessions.append(session_info)
|
|
245
|
+
|
|
246
|
+
# Sort by modified time, newest first
|
|
247
|
+
sessions.sort(key=lambda x: x.get("modified", 0), reverse=True)
|
|
248
|
+
return sessions
|
|
249
|
+
|
|
250
|
+
|
|
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
|
+
|
|
366
|
+
|
|
367
|
+
def get_planning_tools(
|
|
368
|
+
plan_state: Optional[PlanState] = None,
|
|
369
|
+
plan_callback: Optional[Callable] = None,
|
|
370
|
+
session_id: Optional[str] = None
|
|
371
|
+
) -> tuple[List[BaseTool], PlanState]:
|
|
372
|
+
"""
|
|
373
|
+
Get planning tools with shared state.
|
|
374
|
+
|
|
375
|
+
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.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Tuple of (list of tools, plan state object)
|
|
383
|
+
"""
|
|
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()
|
|
397
|
+
|
|
398
|
+
tools = [
|
|
399
|
+
UpdatePlanTool(plan_state=state, plan_callback=plan_callback),
|
|
400
|
+
CompleteStepTool(plan_state=state, plan_callback=plan_callback),
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
return tools, state
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal command execution tools for CLI agents.
|
|
3
|
+
|
|
4
|
+
Provides secure shell command execution restricted to mounted directories
|
|
5
|
+
with blocked command patterns and path traversal protection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import shlex
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, List, Dict, Any
|
|
14
|
+
from langchain_core.tools import BaseTool
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Default blocked command patterns (security)
|
|
22
|
+
DEFAULT_BLOCKED_PATTERNS = [
|
|
23
|
+
# Destructive commands
|
|
24
|
+
r"rm\s+-rf\s+/",
|
|
25
|
+
r"rm\s+-rf\s+~",
|
|
26
|
+
r"rm\s+-rf\s+\*",
|
|
27
|
+
r"rm\s+-rf\s+\.\.",
|
|
28
|
+
r"sudo\s+rm",
|
|
29
|
+
r"mkfs",
|
|
30
|
+
r"dd\s+if=",
|
|
31
|
+
r":\(\)\{\s*:\|:&\s*\};:", # Fork bomb
|
|
32
|
+
|
|
33
|
+
# Privilege escalation
|
|
34
|
+
r"sudo\s+su",
|
|
35
|
+
r"sudo\s+-i",
|
|
36
|
+
r"sudo\s+-s",
|
|
37
|
+
r"chmod\s+777",
|
|
38
|
+
r"chmod\s+-R\s+777",
|
|
39
|
+
r"chown\s+root",
|
|
40
|
+
|
|
41
|
+
# Data exfiltration
|
|
42
|
+
r"curl.*\|.*sh",
|
|
43
|
+
r"wget.*\|.*sh",
|
|
44
|
+
r"curl.*\|.*bash",
|
|
45
|
+
r"wget.*\|.*bash",
|
|
46
|
+
r"nc\s+-e",
|
|
47
|
+
r"/dev/tcp",
|
|
48
|
+
|
|
49
|
+
# System modification
|
|
50
|
+
r"shutdown",
|
|
51
|
+
r"reboot",
|
|
52
|
+
r"init\s+0",
|
|
53
|
+
r"init\s+6",
|
|
54
|
+
r"systemctl\s+stop",
|
|
55
|
+
r"systemctl\s+disable",
|
|
56
|
+
r"launchctl\s+unload",
|
|
57
|
+
|
|
58
|
+
# Path traversal attempts
|
|
59
|
+
r"\.\./\.\./\.\.",
|
|
60
|
+
r"/etc/passwd",
|
|
61
|
+
r"/etc/shadow",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TerminalRunCommandInput(BaseModel):
|
|
66
|
+
"""Input for running a terminal command."""
|
|
67
|
+
command: str = Field(description="Shell command to execute")
|
|
68
|
+
timeout: int = Field(default=300, description="Timeout in seconds (default: 300)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TerminalRunCommandTool(BaseTool):
|
|
72
|
+
"""Execute shell commands in the mounted working directory."""
|
|
73
|
+
|
|
74
|
+
name: str = "terminal_run_command"
|
|
75
|
+
description: str = """Execute a shell command in the workspace directory.
|
|
76
|
+
|
|
77
|
+
Use this to run tests, build commands, git operations, package managers, etc.
|
|
78
|
+
Commands are executed in the mounted workspace directory.
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
- Run tests: `npm test`, `pytest`, `go test ./...`
|
|
82
|
+
- Build: `npm run build`, `cargo build`, `make`
|
|
83
|
+
- Git: `git status`, `git diff`, `git log --oneline -10`
|
|
84
|
+
- Package managers: `npm install`, `pip install -r requirements.txt`
|
|
85
|
+
|
|
86
|
+
The command runs with the workspace as the current working directory.
|
|
87
|
+
Returns stdout, stderr, and exit code."""
|
|
88
|
+
args_schema: type[BaseModel] = TerminalRunCommandInput
|
|
89
|
+
|
|
90
|
+
work_dir: str = ""
|
|
91
|
+
blocked_patterns: List[str] = []
|
|
92
|
+
|
|
93
|
+
def __init__(self, work_dir: str, blocked_patterns: Optional[List[str]] = None, **kwargs):
|
|
94
|
+
super().__init__(**kwargs)
|
|
95
|
+
self.work_dir = str(Path(work_dir).resolve())
|
|
96
|
+
self.blocked_patterns = blocked_patterns or DEFAULT_BLOCKED_PATTERNS
|
|
97
|
+
|
|
98
|
+
def _is_command_blocked(self, command: str) -> tuple[bool, str]:
|
|
99
|
+
"""Check if command matches any blocked patterns."""
|
|
100
|
+
command_lower = command.lower()
|
|
101
|
+
for pattern in self.blocked_patterns:
|
|
102
|
+
if re.search(pattern, command_lower, re.IGNORECASE):
|
|
103
|
+
return True, pattern
|
|
104
|
+
return False, ""
|
|
105
|
+
|
|
106
|
+
def _validate_paths_in_command(self, command: str) -> tuple[bool, str]:
|
|
107
|
+
"""
|
|
108
|
+
Validate that any paths referenced in the command don't escape work_dir.
|
|
109
|
+
This is a best-effort check for obvious path traversal.
|
|
110
|
+
"""
|
|
111
|
+
# Check for obvious path traversal patterns
|
|
112
|
+
if "../../../" in command or "/.." in command:
|
|
113
|
+
return False, "Path traversal detected"
|
|
114
|
+
|
|
115
|
+
# Check for absolute paths outside work_dir
|
|
116
|
+
parts = shlex.split(command)
|
|
117
|
+
for part in parts:
|
|
118
|
+
if part.startswith("/") and not part.startswith(self.work_dir):
|
|
119
|
+
# Allow common system paths that are safe to reference
|
|
120
|
+
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}"
|
|
123
|
+
|
|
124
|
+
return True, ""
|
|
125
|
+
|
|
126
|
+
def _run(self, command: str, timeout: int = 300) -> str:
|
|
127
|
+
"""Execute the command and return results."""
|
|
128
|
+
# Check if command is blocked
|
|
129
|
+
is_blocked, pattern = self._is_command_blocked(command)
|
|
130
|
+
if is_blocked:
|
|
131
|
+
return f"❌ Command blocked for security reasons.\nMatched pattern: {pattern}\n\nThis command pattern is not allowed. Please use a safer alternative."
|
|
132
|
+
|
|
133
|
+
# Validate paths in command
|
|
134
|
+
path_valid, path_error = self._validate_paths_in_command(command)
|
|
135
|
+
if not path_valid:
|
|
136
|
+
return f"❌ Command rejected: {path_error}\n\nCommands must operate within the workspace directory: {self.work_dir}"
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
# Execute command in work_dir
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
command,
|
|
142
|
+
shell=True,
|
|
143
|
+
cwd=self.work_dir,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
env={**os.environ, "PWD": self.work_dir}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
output_parts = []
|
|
151
|
+
|
|
152
|
+
if result.stdout:
|
|
153
|
+
output_parts.append(f"stdout:\n{result.stdout}")
|
|
154
|
+
|
|
155
|
+
if result.stderr:
|
|
156
|
+
output_parts.append(f"stderr:\n{result.stderr}")
|
|
157
|
+
|
|
158
|
+
output_parts.append(f"exit_code: {result.returncode}")
|
|
159
|
+
|
|
160
|
+
return "\n\n".join(output_parts)
|
|
161
|
+
|
|
162
|
+
except subprocess.TimeoutExpired:
|
|
163
|
+
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
|
+
except Exception as e:
|
|
165
|
+
return f"❌ Error executing command: {str(e)}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_blocked_patterns(config_path: Optional[str] = None) -> List[str]:
|
|
169
|
+
"""
|
|
170
|
+
Load blocked command patterns from config file.
|
|
171
|
+
Falls back to defaults if file doesn't exist.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
config_path: Path to blocked_commands.txt file
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of regex patterns
|
|
178
|
+
"""
|
|
179
|
+
patterns = list(DEFAULT_BLOCKED_PATTERNS)
|
|
180
|
+
|
|
181
|
+
if config_path and Path(config_path).exists():
|
|
182
|
+
try:
|
|
183
|
+
content = Path(config_path).read_text()
|
|
184
|
+
for line in content.splitlines():
|
|
185
|
+
line = line.strip()
|
|
186
|
+
# Skip empty lines and comments
|
|
187
|
+
if line and not line.startswith("#"):
|
|
188
|
+
patterns.append(line)
|
|
189
|
+
logger.debug(f"Loaded {len(patterns)} blocked patterns from {config_path}")
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning(f"Failed to load blocked patterns from {config_path}: {e}")
|
|
192
|
+
|
|
193
|
+
return patterns
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_terminal_tools(
|
|
197
|
+
work_dir: str,
|
|
198
|
+
blocked_patterns_path: Optional[str] = None
|
|
199
|
+
) -> List[BaseTool]:
|
|
200
|
+
"""
|
|
201
|
+
Get terminal execution tools for the given working directory.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
work_dir: The workspace directory (must be absolute path)
|
|
205
|
+
blocked_patterns_path: Optional path to custom blocked_commands.txt
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of terminal tools
|
|
209
|
+
"""
|
|
210
|
+
work_dir = str(Path(work_dir).resolve())
|
|
211
|
+
|
|
212
|
+
if not Path(work_dir).is_dir():
|
|
213
|
+
raise ValueError(f"Work directory does not exist: {work_dir}")
|
|
214
|
+
|
|
215
|
+
blocked_patterns = load_blocked_patterns(blocked_patterns_path)
|
|
216
|
+
|
|
217
|
+
return [
|
|
218
|
+
TerminalRunCommandTool(
|
|
219
|
+
work_dir=work_dir,
|
|
220
|
+
blocked_patterns=blocked_patterns
|
|
221
|
+
)
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def create_default_blocked_patterns_file(config_dir: str) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Create default blocked_commands.txt file in config directory.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
config_dir: Directory to create the file in (e.g., $ALITA_DIR/security)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Path to created file
|
|
234
|
+
"""
|
|
235
|
+
security_dir = Path(config_dir) / "security"
|
|
236
|
+
security_dir.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
|
|
238
|
+
blocked_file = security_dir / "blocked_commands.txt"
|
|
239
|
+
|
|
240
|
+
if not blocked_file.exists():
|
|
241
|
+
content = """# Blocked Command Patterns for Alita CLI
|
|
242
|
+
# Each line is a regex pattern. Lines starting with # are comments.
|
|
243
|
+
# These patterns are checked against commands before execution.
|
|
244
|
+
|
|
245
|
+
# === Destructive Commands ===
|
|
246
|
+
rm\\s+-rf\\s+/
|
|
247
|
+
rm\\s+-rf\\s+~
|
|
248
|
+
rm\\s+-rf\\s+\\*
|
|
249
|
+
sudo\\s+rm
|
|
250
|
+
mkfs
|
|
251
|
+
dd\\s+if=
|
|
252
|
+
|
|
253
|
+
# === Privilege Escalation ===
|
|
254
|
+
sudo\\s+su
|
|
255
|
+
sudo\\s+-i
|
|
256
|
+
chmod\\s+777
|
|
257
|
+
chown\\s+root
|
|
258
|
+
|
|
259
|
+
# === Data Exfiltration ===
|
|
260
|
+
curl.*\\|.*sh
|
|
261
|
+
wget.*\\|.*sh
|
|
262
|
+
nc\\s+-e
|
|
263
|
+
|
|
264
|
+
# === System Modification ===
|
|
265
|
+
shutdown
|
|
266
|
+
reboot
|
|
267
|
+
init\\s+0
|
|
268
|
+
systemctl\\s+stop
|
|
269
|
+
|
|
270
|
+
# === Path Traversal ===
|
|
271
|
+
\\.\\./\\.\\./\\.\\.
|
|
272
|
+
/etc/passwd
|
|
273
|
+
/etc/shadow
|
|
274
|
+
|
|
275
|
+
# Add your custom patterns below:
|
|
276
|
+
"""
|
|
277
|
+
blocked_file.write_text(content)
|
|
278
|
+
logger.info(f"Created default blocked patterns file: {blocked_file}")
|
|
279
|
+
|
|
280
|
+
return str(blocked_file)
|